Merge branch 'main' into feature/nation-general-lists
# Conflicts: # app/game-frontend/package.json # app/game-frontend/src/router/index.ts
This commit is contained in:
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
|
||||
import { processBattleSimJob } from './processor.js';
|
||||
|
||||
export class InMemoryBattleSimTransport {
|
||||
private readonly results = new Map<string, BattleSimResultPayload>();
|
||||
private readonly results = new Map<string, { requesterUserId: string; payload: BattleSimResultPayload }>();
|
||||
|
||||
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
|
||||
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
|
||||
const jobId = crypto.randomUUID();
|
||||
const result = processBattleSimJob(payload);
|
||||
this.results.set(jobId, result);
|
||||
this.results.set(jobId, { requesterUserId, payload: result });
|
||||
return { status: 'completed', jobId, payload: result };
|
||||
}
|
||||
|
||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
||||
return this.results.get(jobId) ?? null;
|
||||
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||
const result = this.results.get(jobId);
|
||||
if (!result || result.requesterUserId !== requesterUserId) {
|
||||
return null;
|
||||
}
|
||||
return result.payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
|
||||
this.resultTtlSeconds = options.resultTtlSeconds;
|
||||
}
|
||||
|
||||
private buildResultKey(jobId: string): string {
|
||||
return `${this.keys.resultKeyPrefix}${jobId}`;
|
||||
private buildResultKey(jobId: string, requesterUserId: string): string {
|
||||
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||
}
|
||||
|
||||
private buildNotifyKey(jobId: string): string {
|
||||
return `${this.keys.notifyKeyPrefix}${jobId}`;
|
||||
private buildNotifyKey(jobId: string, requesterUserId: string): string {
|
||||
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||
}
|
||||
|
||||
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
||||
const raw = await this.client.get(this.buildResultKey(jobId));
|
||||
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
|
||||
const existing = await this.readResult(jobId);
|
||||
private async waitForResult(
|
||||
jobId: string,
|
||||
requesterUserId: string,
|
||||
timeoutMs: number
|
||||
): Promise<BattleSimResultPayload | null> {
|
||||
const existing = await this.readResult(jobId, requesterUserId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const notifyKey = this.buildNotifyKey(jobId);
|
||||
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
||||
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
||||
if (!parseBlPopValue(signal)) {
|
||||
return null;
|
||||
}
|
||||
return this.readResult(jobId);
|
||||
return this.readResult(jobId, requesterUserId);
|
||||
}
|
||||
|
||||
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
|
||||
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
|
||||
const jobId = crypto.randomUUID();
|
||||
const job = {
|
||||
jobId,
|
||||
requesterUserId,
|
||||
requestedAt: new Date().toISOString(),
|
||||
payload,
|
||||
};
|
||||
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
|
||||
|
||||
const result = await this.waitForResult(jobId, this.requestTimeoutMs);
|
||||
const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs);
|
||||
if (result) {
|
||||
return { status: 'completed', jobId, payload: result };
|
||||
}
|
||||
return { status: 'queued', jobId };
|
||||
}
|
||||
|
||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
||||
return this.readResult(jobId);
|
||||
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||
return this.readResult(jobId, requesterUserId);
|
||||
}
|
||||
|
||||
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
|
||||
const resultKey = this.buildResultKey(jobId);
|
||||
const notifyKey = this.buildNotifyKey(jobId);
|
||||
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
|
||||
const resultKey = this.buildResultKey(jobId, requesterUserId);
|
||||
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||
await this.client.set(resultKey, JSON.stringify(payload), {
|
||||
EX: this.resultTtlSeconds,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
||||
|
||||
export interface BattleSimTransport {
|
||||
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
|
||||
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
|
||||
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
|
||||
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
|
||||
|
||||
export interface BattleSimJob {
|
||||
jobId: string;
|
||||
requesterUserId: string;
|
||||
requestedAt: string;
|
||||
payload: BattleSimJobPayload;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
|
||||
return result.element ?? null;
|
||||
};
|
||||
|
||||
export const runBattleSimWorker = async (): Promise<void> => {
|
||||
export interface BattleSimWorkerOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise<void> => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
await redis.connect();
|
||||
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
|
||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||
});
|
||||
|
||||
const handleExit = async () => {
|
||||
await redis.disconnect();
|
||||
let stopped = options.signal?.aborted ?? false;
|
||||
const handleExit = () => {
|
||||
stopped = true;
|
||||
};
|
||||
const handleAbort = () => {
|
||||
stopped = true;
|
||||
};
|
||||
process.on('SIGINT', handleExit);
|
||||
process.on('SIGTERM', handleExit);
|
||||
options.signal?.addEventListener('abort', handleAbort, { once: true });
|
||||
|
||||
while (true) {
|
||||
const item = await redis.client.blPop(keys.queueKey, 0);
|
||||
const raw = parseBlPopValue(item);
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
while (!stopped) {
|
||||
// A finite block lets SIGTERM and test AbortSignal stop the worker without
|
||||
// leaving a Redis operation or a detached lifecycle process behind.
|
||||
const item = await redis.client.blPop(keys.queueKey, 1);
|
||||
const raw = parseBlPopValue(item);
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let job: BattleSimJob | null = null;
|
||||
try {
|
||||
job = JSON.parse(raw) as BattleSimJob;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let job: BattleSimJob;
|
||||
try {
|
||||
job = JSON.parse(raw) as BattleSimJob;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = processBattleSimJob(job.payload);
|
||||
await transport.pushResult(job.jobId, result);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||
await transport.pushResult(job.jobId, {
|
||||
result: false,
|
||||
reason,
|
||||
});
|
||||
try {
|
||||
const result = processBattleSimJob(job.payload);
|
||||
await transport.pushResult(job.jobId, job.requesterUserId, result);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||
await transport.pushResult(job.jobId, job.requesterUserId, {
|
||||
result: false,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
process.off('SIGINT', handleExit);
|
||||
process.off('SIGTERM', handleExit);
|
||||
options.signal?.removeEventListener('abort', handleAbort);
|
||||
await redis.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface GameApiConfig {
|
||||
export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env): GameApiConfig => {
|
||||
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'hwe';
|
||||
const scenario = env.SCENARIO ?? 'default';
|
||||
const profileName = `${profile}:${scenario}`;
|
||||
const profileName = env.GAME_PROFILE_NAME ?? `${profile}:${scenario}`;
|
||||
const secret = env.GAME_TOKEN_SECRET ?? env.GATEWAY_TOKEN_SECRET ?? '';
|
||||
if (!secret) {
|
||||
throw new Error('GAME_TOKEN_SECRET is required for game token verification.');
|
||||
|
||||
@@ -23,13 +23,14 @@ export const resolveNationInfo = async (
|
||||
|
||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||
const nation = await resolveNationInfo(db, general.nationId);
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: general.nationId,
|
||||
nationName: nation.name,
|
||||
color: nation.color,
|
||||
icon: '',
|
||||
icon: general.imageServer ? `d_pic/${picture}` : `/image/icons/${picture}`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -594,7 +594,7 @@ export const auctionRouter = router({
|
||||
auctionId: auction.id,
|
||||
generalId: general.id,
|
||||
amount: input.amount,
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? true,
|
||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||
});
|
||||
if (!result || result.type !== 'auctionBid') {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { getDexLevel } from '@sammo-ts/logic';
|
||||
|
||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
||||
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
||||
import {
|
||||
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => {
|
||||
const userId = auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' });
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
const resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
|
||||
const expLevel = meta.explevel ?? meta.expLevel;
|
||||
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
|
||||
@@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record<string, unknown>, key: string): number =>
|
||||
};
|
||||
|
||||
export const battleRouter = router({
|
||||
simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
@@ -58,10 +66,10 @@ export const battleRouter = router({
|
||||
}
|
||||
|
||||
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
||||
return ctx.battleSim.simulate(payload);
|
||||
return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth));
|
||||
}),
|
||||
getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
||||
const result = await ctx.battleSim.getSimulationResult(input.jobId);
|
||||
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
||||
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
|
||||
if (!result) {
|
||||
return { status: 'queued', jobId: input.jobId };
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||
|
||||
const zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']);
|
||||
|
||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
@@ -22,7 +20,7 @@ const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], n
|
||||
return resolveNationPermission(general, nation.meta, true);
|
||||
};
|
||||
|
||||
const mapLetterState = (state: string): z.infer<typeof zLetterState> => {
|
||||
const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => {
|
||||
if (state === 'ACTIVATED') return 'ACTIVATED';
|
||||
if (state === 'CANCELLED') return 'CANCELLED';
|
||||
if (state === 'REPLACED') return 'REPLACED';
|
||||
@@ -153,7 +151,10 @@ export const diplomacyRouter = router({
|
||||
select: { id: true },
|
||||
});
|
||||
if (newer) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.' });
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '해당 문서에 대한 새로운 문서가 이미 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (prevLetter.state === 'PROPOSED') {
|
||||
@@ -169,7 +170,8 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
destNationId =
|
||||
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
|
||||
}
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
@@ -372,4 +374,4 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
return { state: 'ACTIVATED' };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,22 +2,62 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { router, procedure } from '../../trpc.js';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
|
||||
const zDynastyDetailInput = z.object({
|
||||
emperorId: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const parseNumberArray = (value: unknown): number[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is number => typeof item === 'number') : [];
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
: [];
|
||||
|
||||
const parseTextArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
const parseDisplayArray = (value: unknown): Array<string | number> =>
|
||||
Array.isArray(value)
|
||||
? value.filter(
|
||||
(item): item is string | number =>
|
||||
typeof item === 'string' || (typeof item === 'number' && Number.isFinite(item))
|
||||
)
|
||||
: [];
|
||||
|
||||
const formatNationType = (typeCode: string): string => {
|
||||
const separator = typeCode.indexOf('_');
|
||||
return separator < 0 ? typeCode : typeCode.slice(separator + 1);
|
||||
};
|
||||
|
||||
const formatNationLevel = (level: number | null): string => {
|
||||
if (level === null) {
|
||||
return '';
|
||||
}
|
||||
return ['방랑군', '호족', '군벌', '주자사', '주목', '공', '왕', '황제'][level] ?? String(level);
|
||||
};
|
||||
|
||||
export const dynastyRouter = router({
|
||||
getList: procedure.query(async ({ ctx }) => {
|
||||
const rows = await ctx.db.emperor.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
const [worldState, rows] = await Promise.all([
|
||||
ctx.db.worldState.findFirst({
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.emperor.findMany({
|
||||
orderBy: { id: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
current: worldState
|
||||
? {
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
}
|
||||
: null,
|
||||
entries: rows.map((row) => ({
|
||||
id: row.id,
|
||||
serverId: row.serverId ?? '',
|
||||
@@ -30,11 +70,19 @@ export const dynastyRouter = router({
|
||||
power: row.power ?? 0,
|
||||
gennum: row.gennum ?? 0,
|
||||
citynum: row.citynum ?? 0,
|
||||
l12name: row.l12name ?? '',
|
||||
l11name: row.l11name ?? '',
|
||||
l10name: row.l10name ?? '',
|
||||
l9name: row.l9name ?? '',
|
||||
l8name: row.l8name ?? '',
|
||||
l7name: row.l7name ?? '',
|
||||
l6name: row.l6name ?? '',
|
||||
l5name: row.l5name ?? '',
|
||||
})),
|
||||
};
|
||||
}),
|
||||
getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => {
|
||||
const emperor = await ctx.db.emperor.findFirst({
|
||||
const emperor = await ctx.db.emperor.findUnique({
|
||||
where: { id: input.emperorId },
|
||||
});
|
||||
if (!emperor) {
|
||||
@@ -43,7 +91,6 @@ export const dynastyRouter = router({
|
||||
|
||||
const aux = asRecord(emperor.aux);
|
||||
const winnerNationId = typeof aux.winnerNationId === 'number' ? aux.winnerNationId : null;
|
||||
|
||||
const serverId = emperor.serverId ?? '';
|
||||
const oldNationRows = await ctx.db.oldNation.findMany({
|
||||
where: { serverId },
|
||||
@@ -54,19 +101,26 @@ export const dynastyRouter = router({
|
||||
.map((row) => {
|
||||
const data = asRecord(row.data);
|
||||
const nationId = row.nation ?? (typeof data.nation === 'number' ? data.nation : 0);
|
||||
const typeCode = typeof data.type === 'string' ? data.type : '';
|
||||
return {
|
||||
nation: nationId,
|
||||
isWinner: winnerNationId !== null && nationId === winnerNationId,
|
||||
name: typeof data.name === 'string' ? data.name : row.nation === 0 ? '재야' : '미상',
|
||||
name: typeof data.name === 'string' ? data.name : nationId === 0 ? '재야' : '미상',
|
||||
color: typeof data.color === 'string' ? data.color : '#000000',
|
||||
type: typeof data.type === 'string' ? data.type : '',
|
||||
type: typeCode,
|
||||
typeName: formatNationType(typeCode),
|
||||
level: typeof data.level === 'number' ? data.level : null,
|
||||
tech: typeof data.tech === 'number' ? data.tech : null,
|
||||
maxPower: typeof data.maxPower === 'number' ? data.maxPower : null,
|
||||
maxPower:
|
||||
typeof data.maxPower === 'number'
|
||||
? data.maxPower
|
||||
: typeof data.power === 'number'
|
||||
? data.power
|
||||
: null,
|
||||
maxCrew: typeof data.maxCrew === 'number' ? data.maxCrew : null,
|
||||
maxCities: Array.isArray(data.maxCities) ? data.maxCities : [],
|
||||
maxCities: parseDisplayArray(data.maxCities),
|
||||
generals: parseNumberArray(data.generals),
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
history: parseTextArray(data.history),
|
||||
date: row.date.toISOString(),
|
||||
};
|
||||
})
|
||||
@@ -89,6 +143,7 @@ export const dynastyRouter = router({
|
||||
|
||||
const nations = nationEntries.map((entry) => ({
|
||||
...entry,
|
||||
levelName: formatNationLevel(entry.level),
|
||||
generalsFull: entry.generals.map((id) => ({
|
||||
generalNo: id,
|
||||
name: generalMap.get(id)?.name ?? `#${id}`,
|
||||
@@ -131,7 +186,7 @@ export const dynastyRouter = router({
|
||||
tiger: emperor.tiger ?? '',
|
||||
eagle: emperor.eagle ?? '',
|
||||
gen: emperor.gen ?? '',
|
||||
history: Array.isArray(emperor.history) ? emperor.history : [],
|
||||
history: parseTextArray(emperor.history),
|
||||
},
|
||||
nations,
|
||||
};
|
||||
|
||||
@@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => {
|
||||
};
|
||||
|
||||
const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||
const settings = asRecord(meta.userSettings);
|
||||
const mysetRaw = settings.myset;
|
||||
// The legacy general columns are persisted at the top level of General.meta.
|
||||
// Keep reading the short-lived nested shape for installations that ran the
|
||||
// initial rewrite implementation before this compatibility fix.
|
||||
const nestedSettings = asRecord(meta.userSettings);
|
||||
const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key];
|
||||
const mysetRaw = readSetting('myset');
|
||||
const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null;
|
||||
|
||||
return {
|
||||
tnmt: readNumber(settings.tnmt, 1),
|
||||
defence_train: readNumber(settings.defence_train, 80),
|
||||
use_treatment: readNumber(settings.use_treatment, 10),
|
||||
use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1),
|
||||
tnmt: readNumber(readSetting('tnmt'), 1),
|
||||
defence_train: readNumber(readSetting('defence_train'), 80),
|
||||
use_treatment: readNumber(readSetting('use_treatment'), 10),
|
||||
use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1),
|
||||
myset,
|
||||
};
|
||||
};
|
||||
@@ -262,29 +266,6 @@ export const generalRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason });
|
||||
}
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const prevSettings = asRecord(metaRecord.userSettings);
|
||||
const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset)
|
||||
? prevSettings.myset
|
||||
: null;
|
||||
const nextSettings = {
|
||||
...prevSettings,
|
||||
...input,
|
||||
} as Record<string, unknown>;
|
||||
if (typeof prevMyset === 'number') {
|
||||
nextSettings.myset = Math.max(0, prevMyset - 1);
|
||||
}
|
||||
|
||||
await ctx.db.general.update({
|
||||
where: { id: general.id },
|
||||
data: {
|
||||
meta: {
|
||||
...metaRecord,
|
||||
userSettings: nextSettings,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import {
|
||||
@@ -26,6 +28,75 @@ import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
const redactDiplomacyMessages = (messages: MessageView[], permission: number): MessageView[] => {
|
||||
if (permission >= 3) {
|
||||
return messages;
|
||||
}
|
||||
return messages.map((message) => {
|
||||
if (!message.dest || message.dest.nationId === 0) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
text: '(외교 메시지입니다)',
|
||||
option: {
|
||||
...(message.option ?? {}),
|
||||
invalid: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isFutureDate = (value: string | undefined, now = Date.now()): boolean => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) && parsed > now;
|
||||
};
|
||||
|
||||
const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => {
|
||||
if (
|
||||
isFutureDate(sanctions.mutedUntil) ||
|
||||
isFutureDate(sanctions.suspendedUntil) ||
|
||||
isFutureDate(sanctions.bannedUntil)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const profileName of profileNames) {
|
||||
const restriction = sanctions.serverRestrictions?.[profileName];
|
||||
if (!restriction) {
|
||||
continue;
|
||||
}
|
||||
if (restriction.until && !isFutureDate(restriction.until)) {
|
||||
continue;
|
||||
}
|
||||
if (restriction.blockedFeatures?.includes('messages')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => {
|
||||
const value = asRecord(penalty)[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const hasPenalty = (penalty: unknown, key: string): boolean => {
|
||||
const value = asRecord(penalty)[key];
|
||||
return value === true || value === 1 || value === '1';
|
||||
};
|
||||
|
||||
export const messagesRouter = router({
|
||||
getRecent: authedProcedure
|
||||
.input(
|
||||
@@ -85,11 +156,12 @@ export const messagesRouter = router({
|
||||
: null,
|
||||
]);
|
||||
|
||||
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||
private: privateMessages,
|
||||
public: publicMessages,
|
||||
national: nationalMessages,
|
||||
diplomacy: diplomacyMessages,
|
||||
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
|
||||
};
|
||||
|
||||
let nextSequence = sequence;
|
||||
@@ -128,10 +200,8 @@ export const messagesRouter = router({
|
||||
sequence: nextSequence,
|
||||
nationId: nationId,
|
||||
generalName: general.name,
|
||||
canRespondDiplomacy:
|
||||
general.officerLevel > 4 &&
|
||||
nation !== null &&
|
||||
resolveNationPermission(general, nation.meta, false) >= 4,
|
||||
permission,
|
||||
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
|
||||
latestRead: {
|
||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||
private: readState?.latestPrivateMessage ?? 0,
|
||||
@@ -178,6 +248,7 @@ export const messagesRouter = router({
|
||||
];
|
||||
return {
|
||||
nation: nationList.map((nation) => ({
|
||||
nationId: nation.id,
|
||||
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + nation.id,
|
||||
name: nation.name,
|
||||
color: nation.color,
|
||||
@@ -234,14 +305,24 @@ export const messagesRouter = router({
|
||||
if (message.payload.src.generalId !== general.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '본인의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
if (message.msgType === 'diplomacy' || message.payload.option?.deletable === false) {
|
||||
if (message.msgType === 'diplomacy' && message.payload.option?.action) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '시스템 외교 메시지는 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
if (message.payload.option?.deletable === false) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||
}
|
||||
if (Date.now() - message.time.getTime() > 5 * 60 * 1000) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||
}
|
||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||
const ids = [message.id, ...(typeof receiverMessageId === 'number' ? [receiverMessageId] : [])];
|
||||
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||
const ids = [
|
||||
message.id,
|
||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||
];
|
||||
await invalidateMessages(ctx.db, ids);
|
||||
return { ok: true, deletedIds: ids };
|
||||
}),
|
||||
@@ -291,6 +372,14 @@ export const messagesRouter = router({
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
|
||||
const nationId = general.nationId;
|
||||
const nation =
|
||||
nationId > 0
|
||||
? await ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
|
||||
const mailboxes = {
|
||||
private: general.id,
|
||||
public: MESSAGE_MAILBOX_PUBLIC,
|
||||
@@ -312,7 +401,8 @@ export const messagesRouter = router({
|
||||
toSeq: input.to,
|
||||
limit: 15,
|
||||
});
|
||||
messageBuckets[input.type] = messages;
|
||||
messageBuckets[input.type] =
|
||||
input.type === 'diplomacy' ? redactDiplomacyMessages(messages, permission) : messages;
|
||||
|
||||
return {
|
||||
result: true,
|
||||
@@ -320,6 +410,7 @@ export const messagesRouter = router({
|
||||
sequence: 0,
|
||||
nationId,
|
||||
generalName: general.name,
|
||||
permission,
|
||||
...messageBuckets,
|
||||
};
|
||||
}),
|
||||
@@ -333,6 +424,12 @@ export const messagesRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
if (!ctx.auth || isMessageFeatureBlocked(ctx.auth.sanctions, [ctx.profile.name, ctx.profile.id])) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '메시지 전송이 제한된 계정입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||
const now = new Date();
|
||||
@@ -340,28 +437,93 @@ export const messagesRouter = router({
|
||||
|
||||
let msgType: MessageType;
|
||||
let dest = src;
|
||||
let receiverMailbox = input.mailbox;
|
||||
|
||||
if (input.mailbox === MESSAGE_MAILBOX_PUBLIC) {
|
||||
msgType = 'public';
|
||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||
const destNationId = input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||
if (destNationId <= 0) {
|
||||
if (hasPenalty(general.penalty, 'noSendPublicMsg')) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid nation mailbox.',
|
||||
code: 'FORBIDDEN',
|
||||
message: '공개 메세지를 보낼 수 없습니다.',
|
||||
});
|
||||
}
|
||||
msgType = 'public';
|
||||
} else if (input.mailbox >= MESSAGE_MAILBOX_NATIONAL_BASE) {
|
||||
const sourceNation =
|
||||
general.nationId > 0
|
||||
? await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null;
|
||||
const permission =
|
||||
general.nationId > 0 && sourceNation ? resolveNationPermission(general, sourceNation.meta) : -1;
|
||||
const destNationId = permission < 4 ? general.nationId : input.mailbox - MESSAGE_MAILBOX_NATIONAL_BASE;
|
||||
const nationInfo = await resolveNationInfo(ctx.db, destNationId);
|
||||
if (destNationId > 0) {
|
||||
const destNation = await ctx.db.nation.findUnique({ where: { id: destNationId } });
|
||||
if (!destNation) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: '존재하지 않는 국가입니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
dest = buildNationTarget(destNationId, nationInfo.name, nationInfo.color);
|
||||
msgType = destNationId === general.nationId ? 'national' : 'diplomacy';
|
||||
receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + destNationId;
|
||||
} else if (input.mailbox > 0) {
|
||||
if (hasPenalty(general.penalty, 'noSendPrivateMsg')) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '개인 메세지를 보낼 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const intervalSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil(readPenaltyNumber(general.penalty, 'sendPrivateMsgDelay', 2))
|
||||
);
|
||||
if (intervalSeconds > 0) {
|
||||
const rateLimitKey = `game:${ctx.profile.name}:message:private:${ctx.auth.sessionId}`;
|
||||
const acquired = await ctx.redis.set(rateLimitKey, '1', {
|
||||
NX: true,
|
||||
PX: intervalSeconds * 1000,
|
||||
});
|
||||
if (acquired === null) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: `개인메세지는 ${intervalSeconds}초당 1건만 보낼 수 있습니다!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const destGeneral = await ctx.db.general.findUnique({
|
||||
where: { id: input.mailbox },
|
||||
});
|
||||
if (!destGeneral) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Destination general not found.',
|
||||
message: '존재하지 않는 유저입니다.',
|
||||
});
|
||||
}
|
||||
const [sourceNation, destNation] = await Promise.all([
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } })
|
||||
: null,
|
||||
destGeneral.nationId > 0
|
||||
? ctx.db.nation.findUnique({ where: { id: destGeneral.nationId }, select: { meta: true } })
|
||||
: null,
|
||||
]);
|
||||
const sourcePermission =
|
||||
sourceNation && general.nationId > 0
|
||||
? resolveNationPermission(general, sourceNation.meta, false)
|
||||
: -1;
|
||||
const destPermission =
|
||||
destNation && destGeneral.nationId > 0
|
||||
? resolveNationPermission(destGeneral, destNation.meta, false)
|
||||
: -1;
|
||||
if (sourcePermission === 4 && destPermission === 4 && destGeneral.nationId !== general.nationId) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
||||
});
|
||||
}
|
||||
dest = await buildTargetFromGeneral(ctx.db, destGeneral);
|
||||
@@ -394,7 +556,7 @@ export const messagesRouter = router({
|
||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||
type: 'messageCreated',
|
||||
at: now.toISOString(),
|
||||
mailbox: input.mailbox,
|
||||
mailbox: receiverMailbox,
|
||||
msgType,
|
||||
messageId: result.receiverId,
|
||||
senderId: general.id,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const;
|
||||
type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number];
|
||||
type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number];
|
||||
|
||||
const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset');
|
||||
|
||||
const readNumber = (value: unknown, fallback = 0): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -353,8 +349,8 @@ const buildZeroPolicy = async (
|
||||
}
|
||||
): Promise<NationPolicy> => {
|
||||
const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options;
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT });
|
||||
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId);
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||
const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0);
|
||||
const techCost = getTechCost(nationTech);
|
||||
const next = clonePolicy(policy);
|
||||
|
||||
@@ -364,7 +360,7 @@ const buildZeroPolicy = async (
|
||||
|
||||
if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) {
|
||||
const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0;
|
||||
const baseRice = statNpcMax;
|
||||
const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0;
|
||||
if (next.reqNPCWarGold === 0) {
|
||||
next.reqNPCWarGold = roundTo(baseGold * 4, -2);
|
||||
}
|
||||
@@ -375,7 +371,7 @@ const buildZeroPolicy = async (
|
||||
|
||||
if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) {
|
||||
const baseGold = crewType ? crewType.cost * techCost * statMax : 0;
|
||||
const baseRice = statMax;
|
||||
const baseRice = crewType ? crewType.rice * techCost * statMax : 0;
|
||||
if (next.reqHumanWarUrgentGold === 0) {
|
||||
next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2);
|
||||
}
|
||||
@@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record<string, unknown>, kind: 'value' | 'pri
|
||||
};
|
||||
};
|
||||
|
||||
const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority));
|
||||
|
||||
const validateGeneralPriority = (priority: string[]): string | null => {
|
||||
const orderRequired: Array<[string, string]> = [['출병', '일반내정']];
|
||||
const mustHave = new Set(['출병', '일반내정']);
|
||||
@@ -461,6 +455,7 @@ export const npcRouter = router({
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
tech: true,
|
||||
meta: true,
|
||||
},
|
||||
}),
|
||||
@@ -508,9 +503,9 @@ export const npcRouter = router({
|
||||
const stat = resolveScenarioStat(config);
|
||||
const env = resolveCommandEnv(config);
|
||||
const unitSetName = resolveUnitSetName(config, 'che');
|
||||
const nationTech = readNumber(asRecord(nationMeta).tech, 0);
|
||||
const nationTech = readNumber(nation.tech, 0);
|
||||
|
||||
const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, {
|
||||
const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, {
|
||||
statMax: stat.max,
|
||||
statNpcMax: stat.npcMax,
|
||||
nationTech,
|
||||
@@ -542,277 +537,272 @@ export const npcRouter = router({
|
||||
permissionLevel,
|
||||
};
|
||||
}),
|
||||
setNationPolicy: authedProcedure
|
||||
.input(z.record(z.string(), z.unknown()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const keys = Object.keys(input);
|
||||
for (const key of keys) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
const keys = Object.keys(input);
|
||||
for (const key of keys) {
|
||||
if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const troopRows = await ctx.db.troop.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
select: { troopLeaderId: true },
|
||||
});
|
||||
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
||||
|
||||
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
||||
const citySet = new Set(cityRows.map((row) => row.id));
|
||||
const assigned = new Set<number>();
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
||||
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
}
|
||||
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = value;
|
||||
}
|
||||
|
||||
if ('CombatForce' in input) {
|
||||
const rawCombat = input.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
||||
}
|
||||
const combatForce: Record<number, [number, number]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawKey);
|
||||
if (!Number.isFinite(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 입력양식이 올바르지 않습니다.`,
|
||||
});
|
||||
}
|
||||
const fromCity = Number(rawValue[0]);
|
||||
const toCity = Number(rawValue[1]);
|
||||
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
combatForce[leaderId] = [fromCity, toCity];
|
||||
assigned.add(leaderId);
|
||||
}
|
||||
nextValues.CombatForce = combatForce;
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = input[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawValue of rawList) {
|
||||
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
const troopRows = await ctx.db.troop.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
select: { troopLeaderId: true },
|
||||
});
|
||||
const cityRows = await ctx.db.city.findMany({ select: { id: true } });
|
||||
|
||||
const troopSet = new Set(troopRows.map((row) => row.troopLeaderId));
|
||||
const citySet = new Set(cityRows.map((row) => row.id));
|
||||
const assigned = new Set<number>();
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values));
|
||||
|
||||
for (const key of INTEGER_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
if (!troopSet.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
if (assigned.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
assigned.add(rawValue);
|
||||
list.push(rawValue);
|
||||
}
|
||||
|
||||
for (const key of FLOAT_POLICY_KEYS) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const value = input[key];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` });
|
||||
}
|
||||
nextValues[key] = Math.max(0, value);
|
||||
if (key === 'SupportForce') {
|
||||
nextValues.SupportForce = list;
|
||||
} else {
|
||||
nextValues.DevelopForce = list;
|
||||
}
|
||||
}
|
||||
|
||||
if ('CombatForce' in input) {
|
||||
const rawCombat = input.CombatForce;
|
||||
if (!isRecord(rawCombat)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' });
|
||||
}
|
||||
const combatForce: Record<number, [number, number]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(rawCombat)) {
|
||||
const leaderId = Number(rawKey);
|
||||
if (!Number.isFinite(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(leaderId)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` });
|
||||
}
|
||||
if (assigned.has(leaderId)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(rawValue) || rawValue.length < 2) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` });
|
||||
}
|
||||
const fromCity = Number(rawValue[0]);
|
||||
const toCity = Number(rawValue[1]);
|
||||
if (!citySet.has(fromCity) || !citySet.has(toCity)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
combatForce[leaderId] = [fromCity, toCity];
|
||||
assigned.add(leaderId);
|
||||
}
|
||||
nextValues.CombatForce = combatForce;
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
values: nextValues,
|
||||
valueSetter: general.name,
|
||||
valueSetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
for (const item of input) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ['SupportForce', 'DevelopForce'] as const) {
|
||||
if (!(key in input)) {
|
||||
continue;
|
||||
}
|
||||
const rawList = input[key];
|
||||
if (!Array.isArray(rawList)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
const list: number[] = [];
|
||||
for (const rawValue of rawList) {
|
||||
if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` });
|
||||
}
|
||||
if (!troopSet.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `${rawValue}는 국가의 부대가 아닙니다.`,
|
||||
});
|
||||
}
|
||||
if (assigned.has(rawValue)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
assigned.add(rawValue);
|
||||
list.push(rawValue);
|
||||
}
|
||||
if (key === 'SupportForce') {
|
||||
nextValues.SupportForce = list;
|
||||
} else {
|
||||
nextValues.DevelopForce = list;
|
||||
}
|
||||
}
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: input,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
values: nextValues,
|
||||
valueSetter: general.name,
|
||||
valueSetTime: new Date().toISOString(),
|
||||
};
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
return { ok: true };
|
||||
}),
|
||||
setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setNationPriority: authedProcedure
|
||||
.input(z.array(z.string()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
const validationError = validateGeneralPriority(input);
|
||||
if (validationError) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
||||
}
|
||||
|
||||
const unique = ensureUniquePriority(input);
|
||||
for (const item of unique) {
|
||||
if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` });
|
||||
}
|
||||
}
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: input,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_nation_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: unique,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_general_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_nation_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
setGeneralPriority: authedProcedure
|
||||
.input(z.array(z.string()))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
if (general.nationId <= 0) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' });
|
||||
}
|
||||
|
||||
const nation = await ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: { id: true, meta: true },
|
||||
});
|
||||
if (!nation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
|
||||
}
|
||||
|
||||
const permissionLevel = resolveSecretPermission(
|
||||
{
|
||||
nationId: general.nationId,
|
||||
officerLevel: general.officerLevel,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
},
|
||||
nation.meta
|
||||
);
|
||||
if (permissionLevel < 3) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
|
||||
}
|
||||
|
||||
const unique = ensureUniquePriority(input);
|
||||
const validationError = validateGeneralPriority(unique);
|
||||
if (validationError) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: validationError });
|
||||
}
|
||||
|
||||
const nationMeta = asRecord(nation.meta);
|
||||
const policyRoot = asRecord(nationMeta.npc_general_policy);
|
||||
const nextPolicyRoot = {
|
||||
...policyRoot,
|
||||
priority: unique,
|
||||
prioritySetter: general.name,
|
||||
prioritySetTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateNationMeta(
|
||||
ctx,
|
||||
nation.id,
|
||||
{
|
||||
npc_general_policy: nextPolicyRoot,
|
||||
},
|
||||
nationMeta
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
||||
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
type WorldTrendSnapshot = {
|
||||
year: number;
|
||||
@@ -42,6 +43,14 @@ type NationCountRow = {
|
||||
|
||||
type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
|
||||
type TrafficHistoryItem = {
|
||||
year: number;
|
||||
month: number;
|
||||
refresh: number;
|
||||
online: number;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const PUBLIC_CACHE_TTL_SECONDS = 600;
|
||||
|
||||
const buildPublicCacheKey = (ctx: GameApiContext, key: string): string =>
|
||||
@@ -163,6 +172,26 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: TrafficHistoryItem[] = [];
|
||||
for (const item of value) {
|
||||
const row = asRecord(item);
|
||||
const year = readFiniteMetaNumber(row, 'year');
|
||||
const month = readFiniteMetaNumber(row, 'month');
|
||||
const refresh = readFiniteMetaNumber(row, 'refresh');
|
||||
const online = readFiniteMetaNumber(row, 'online');
|
||||
const date = typeof row.date === 'string' ? row.date : '';
|
||||
if (year > 0 && month > 0 && date) {
|
||||
result.push({ year, month, refresh, online, date });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const compareString = (left: string, right: string): number => {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
@@ -203,6 +232,11 @@ const sortNpcList = <T extends {
|
||||
});
|
||||
|
||||
export const publicRouter = router({
|
||||
recordAccess: sessionActivityProcedure
|
||||
.input(z.object({ page: z.enum(accessPages) }))
|
||||
.mutation(async ({ ctx, input }) => ({
|
||||
recorded: await recordGeneralAccess(ctx, input.page),
|
||||
})),
|
||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||
return loadMapLayout(ctx.profile.scenario);
|
||||
}),
|
||||
@@ -222,6 +256,95 @@ export const publicRouter = router({
|
||||
getNationList: procedure.query(async ({ ctx }) => {
|
||||
return loadCachedNationList(ctx);
|
||||
}),
|
||||
getTraffic: procedure.query(async ({ ctx }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const rawOnlineSince = meta.lastTurnTime ?? meta.turntime;
|
||||
const parsedOnlineSince =
|
||||
typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date
|
||||
? new Date(rawOnlineSince)
|
||||
: null;
|
||||
const onlineSince =
|
||||
parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime())
|
||||
? parsedOnlineSince
|
||||
: new Date(Date.now() - worldState.tickSeconds * 1_000);
|
||||
const [accessTotal, currentOnline, topAccess] = await Promise.all([
|
||||
ctx.db.generalAccessLog.aggregate({
|
||||
_sum: {
|
||||
refresh: true,
|
||||
refreshScoreTotal: true,
|
||||
},
|
||||
}),
|
||||
ctx.db.generalAccessLog.count({
|
||||
where: {
|
||||
lastRefresh: {
|
||||
gte: onlineSince,
|
||||
},
|
||||
},
|
||||
}),
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }],
|
||||
take: 5,
|
||||
select: {
|
||||
generalId: true,
|
||||
refresh: true,
|
||||
refreshScoreTotal: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const generalIds = topAccess.map((entry) => entry.generalId);
|
||||
const generalRows =
|
||||
generalIds.length > 0
|
||||
? await ctx.db.general.findMany({
|
||||
where: { id: { in: generalIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const generalName = new Map(generalRows.map((general) => [general.id, general.name]));
|
||||
const totalRefresh = accessTotal._sum.refresh ?? 0;
|
||||
const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0;
|
||||
const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh);
|
||||
const history = parseTrafficHistory(meta.recentTraffic);
|
||||
history.push({
|
||||
year: worldState.currentYear,
|
||||
month: worldState.currentMonth,
|
||||
refresh: currentRefresh,
|
||||
online: currentOnline,
|
||||
date: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
maxRefresh: Math.max(
|
||||
1,
|
||||
readFiniteMetaNumber(meta, 'maxrefresh'),
|
||||
...history.map((entry) => entry.refresh)
|
||||
),
|
||||
maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)),
|
||||
suspects: [
|
||||
{
|
||||
generalId: null,
|
||||
name: '접속자 총합',
|
||||
refresh: totalRefresh,
|
||||
refreshScoreTotal: totalRefreshScore,
|
||||
},
|
||||
...topAccess.map((entry) => ({
|
||||
generalId: entry.generalId,
|
||||
name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`,
|
||||
refresh: entry.refresh,
|
||||
refreshScoreTotal: entry.refreshScoreTotal,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}),
|
||||
getGeneralList: procedure.query(async ({ ctx }) => {
|
||||
const [generals, nations] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
|
||||
@@ -2,11 +2,35 @@ import { z } from 'zod';
|
||||
|
||||
import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common';
|
||||
import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js';
|
||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, procedure, router } from '../../trpc.js';
|
||||
|
||||
const DEFAULT_BG_COLOR = '#2b2b2b';
|
||||
const DEFAULT_BG_COLOR = '#330000';
|
||||
const DEFAULT_FG_COLOR = '#ffffff';
|
||||
const NEUTRAL_BG_COLOR = '#000000';
|
||||
const LEGACY_WHITE_TEXT_COLORS = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
'#FF0000',
|
||||
'#800000',
|
||||
'#A0522D',
|
||||
'#FF6347',
|
||||
'#808000',
|
||||
'#008000',
|
||||
'#2E8B57',
|
||||
'#008080',
|
||||
'#6495ED',
|
||||
'#0000FF',
|
||||
'#000080',
|
||||
'#483D8B',
|
||||
'#7B68EE',
|
||||
'#800080',
|
||||
'#A9A9A9',
|
||||
'#000000',
|
||||
]);
|
||||
|
||||
const readMetaNumber = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -21,33 +45,43 @@ const readMetaNumber = (value: unknown): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`;
|
||||
export const formatLegacyRankingNumber = (value: number, fractionDigits = 0): string =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
useGrouping: true,
|
||||
}).format(value);
|
||||
|
||||
export const resolveLegacyTextColor = (backgroundColor: string): string =>
|
||||
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
|
||||
|
||||
const percentText = (value: number): string => `${formatLegacyRankingNumber(value * 100, 2)}%`;
|
||||
|
||||
const readOwnerDisplayName = (value: unknown): string | null => {
|
||||
const meta = asRecord(value);
|
||||
if (typeof meta.ownerName === 'string' && meta.ownerName.length > 0) {
|
||||
return meta.ownerName;
|
||||
}
|
||||
if (typeof meta.owner_name === 'string' && meta.owner_name.length > 0) {
|
||||
return meta.owner_name;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const itemLoader = new ItemLoader();
|
||||
let cachedUniqueItems: Promise<
|
||||
Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }>
|
||||
> | null = null;
|
||||
let cachedUniqueItems: Promise<ItemModule[]> | null = null;
|
||||
|
||||
const loadUniqueItems = () => {
|
||||
if (!cachedUniqueItems) {
|
||||
cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) =>
|
||||
modules
|
||||
.filter((module) => module.unique && !module.buyable)
|
||||
.map((module) => ({
|
||||
key: module.key,
|
||||
name: module.name,
|
||||
slot: module.slot,
|
||||
unique: module.unique,
|
||||
buyable: module.buyable,
|
||||
info: module.info,
|
||||
}))
|
||||
modules.filter((module) => module.unique && !module.buyable)
|
||||
);
|
||||
}
|
||||
return cachedUniqueItems;
|
||||
};
|
||||
|
||||
export const rankingRouter = router({
|
||||
getBestGeneral: procedure
|
||||
getBestGeneral: authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
@@ -57,7 +91,7 @@ export const rankingRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
select: { meta: true },
|
||||
select: { meta: true, config: true },
|
||||
});
|
||||
const meta = asRecord(worldState?.meta);
|
||||
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
|
||||
@@ -69,6 +103,7 @@ export const rankingRouter = router({
|
||||
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
|
||||
ctx.db.general.findMany({
|
||||
where: { npcState: npcFilter },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -76,6 +111,7 @@ export const rankingRouter = router({
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
horseCode: true,
|
||||
@@ -129,11 +165,11 @@ export const rankingRouter = router({
|
||||
}
|
||||
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
|
||||
}],
|
||||
['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0],
|
||||
['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0],
|
||||
['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0],
|
||||
['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0],
|
||||
['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0],
|
||||
['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
|
||||
['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
|
||||
['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
|
||||
['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
|
||||
['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
|
||||
['전 력 전 승 률', 'percent', (_g, r) => {
|
||||
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
|
||||
if (total < 50) {
|
||||
@@ -182,17 +218,20 @@ export const rankingRouter = router({
|
||||
const ranks = rankMap.get(general.id) ?? {};
|
||||
const value = valueFn(general, ranks);
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
const bgColor =
|
||||
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
let display = {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
ownerName: general.userId ?? null,
|
||||
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
value,
|
||||
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'),
|
||||
printValue:
|
||||
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
|
||||
};
|
||||
|
||||
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
|
||||
@@ -202,7 +241,7 @@ export const rankingRouter = router({
|
||||
ownerName: null,
|
||||
nationName: '???',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
};
|
||||
@@ -217,46 +256,93 @@ export const rankingRouter = router({
|
||||
});
|
||||
|
||||
const uniqueItems = await loadUniqueItems();
|
||||
const itemEntries = uniqueItems.map((item) => {
|
||||
const owners = generals.filter((general) => {
|
||||
if (item.slot === 'horse') {
|
||||
return general.horseCode === item.key;
|
||||
const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item]));
|
||||
const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const));
|
||||
if (Object.keys(uniqueConfig.allItems).length === 0) {
|
||||
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
|
||||
}
|
||||
const activeAuctions = await ctx.db.auction.findMany({
|
||||
where: {
|
||||
type: 'UNIQUE_ITEM',
|
||||
status: { in: ['OPEN', 'FINALIZING'] },
|
||||
targetCode: { not: null },
|
||||
},
|
||||
select: { targetCode: true },
|
||||
});
|
||||
const auctionCounts = new Map<string, number>();
|
||||
for (const auction of activeAuctions) {
|
||||
if (auction.targetCode) {
|
||||
auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const slotTitles = {
|
||||
horse: '명 마',
|
||||
weapon: '명 검',
|
||||
book: '명 서',
|
||||
item: '도 구',
|
||||
} as const;
|
||||
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
|
||||
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse();
|
||||
const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
|
||||
const item = itemRegistry.get(itemKey);
|
||||
if (!item || item.buyable) {
|
||||
return [];
|
||||
}
|
||||
if (item.slot === 'weapon') {
|
||||
return general.weaponCode === item.key;
|
||||
const owners = generals
|
||||
.filter((general) => {
|
||||
if (slot === 'horse') {
|
||||
return general.horseCode === itemKey;
|
||||
}
|
||||
if (slot === 'weapon') {
|
||||
return general.weaponCode === itemKey;
|
||||
}
|
||||
if (slot === 'book') {
|
||||
return general.bookCode === itemKey;
|
||||
}
|
||||
return general.itemCode === itemKey;
|
||||
})
|
||||
.map((general) => {
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
const bgColor =
|
||||
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor,
|
||||
fgColor: resolveLegacyTextColor(bgColor),
|
||||
picture: general.picture ?? null,
|
||||
imageServer: general.imageServer ?? 0,
|
||||
};
|
||||
});
|
||||
for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) {
|
||||
owners.push({
|
||||
id: 0,
|
||||
name: '경매중',
|
||||
nationName: '-',
|
||||
bgColor: '#00582c',
|
||||
fgColor: '#ffffff',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
});
|
||||
}
|
||||
if (item.slot === 'book') {
|
||||
return general.bookCode === item.key;
|
||||
}
|
||||
return general.itemCode === item.key;
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
itemKey,
|
||||
itemName: item.name,
|
||||
itemInfo: item.info,
|
||||
owner: owners[index] ?? {
|
||||
id: 0,
|
||||
name: '미발견',
|
||||
nationName: '-',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const displayOwners = owners.length
|
||||
? owners.map((general) => {
|
||||
const nation = nationMap.get(general.nationId) ?? null;
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
id: 0,
|
||||
name: '미발견',
|
||||
nationName: '-',
|
||||
bgColor: DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
title: item.name,
|
||||
slot: item.slot,
|
||||
owners: displayOwners,
|
||||
};
|
||||
return { title: slotTitles[slot], slot, entries };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -339,13 +425,20 @@ export const rankingRouter = router({
|
||||
return {
|
||||
generalId: row.generalNo,
|
||||
name: String(aux.name ?? ''),
|
||||
ownerName:
|
||||
typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0
|
||||
? aux.ownerDisplayName
|
||||
: null,
|
||||
nationName: String(aux.nationName ?? ''),
|
||||
bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR),
|
||||
fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR),
|
||||
picture: typeof aux.picture === 'string' ? aux.picture : null,
|
||||
imageServer: readMetaNumber(aux.imgsvr),
|
||||
value: row.value,
|
||||
printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'),
|
||||
printValue:
|
||||
type.type === 'percent'
|
||||
? percentText(row.value)
|
||||
: formatLegacyRankingNumber(row.value),
|
||||
serverName: String(aux.serverName ?? ''),
|
||||
serverIdx: readMetaNumber(aux.serverIdx),
|
||||
scenarioName: String(aux.scenarioName ?? ''),
|
||||
|
||||
@@ -7,7 +7,8 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
|
||||
import { procedure, router } from '../../trpc.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
|
||||
type YearbookNation = {
|
||||
id: number;
|
||||
@@ -19,6 +20,7 @@ type YearbookNation = {
|
||||
};
|
||||
|
||||
const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1;
|
||||
const zServerId = z.string().trim().min(1).max(64);
|
||||
|
||||
const computeHash = (payload: unknown): string =>
|
||||
createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
@@ -189,53 +191,86 @@ const buildLogs = async (ctx: GameApiContext, year: number, month: number) => {
|
||||
};
|
||||
|
||||
export const yearbookRouter = router({
|
||||
getRange: procedure.query(async ({ ctx }) => {
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
getRange: authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
serverID: zServerId.optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
const targetProfileName = input?.serverID ?? ctx.profile.name;
|
||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
||||
|
||||
const firstRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: ctx.profile.name },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }],
|
||||
});
|
||||
const firstRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: targetProfileName },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }],
|
||||
});
|
||||
|
||||
const lastRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: ctx.profile.name },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'desc' }, { month: 'desc' }],
|
||||
});
|
||||
const lastRow = await ctx.db.yearbookHistory.findFirst({
|
||||
where: { profileName: targetProfileName },
|
||||
select: { year: true, month: true },
|
||||
orderBy: [{ year: 'desc' }, { month: 'desc' }],
|
||||
});
|
||||
|
||||
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
|
||||
const fallbackYearMonth = currentYearMonth - 1;
|
||||
const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth;
|
||||
const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth;
|
||||
if (!isCurrentProfile && (!firstRow || !lastRow)) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
firstYearMonth,
|
||||
lastYearMonth,
|
||||
currentYearMonth,
|
||||
};
|
||||
}),
|
||||
getHistory: procedure
|
||||
const currentYearMonth = joinYearMonth(worldState.currentYear, worldState.currentMonth);
|
||||
const fallbackYearMonth = currentYearMonth - 1;
|
||||
const firstYearMonth = firstRow
|
||||
? joinYearMonth(firstRow.year, firstRow.month)
|
||||
: fallbackYearMonth;
|
||||
const lastYearMonth = lastRow
|
||||
? joinYearMonth(lastRow.year, lastRow.month)
|
||||
: fallbackYearMonth;
|
||||
const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth;
|
||||
|
||||
return {
|
||||
firstYearMonth,
|
||||
lastYearMonth,
|
||||
currentYearMonth: selectedYearMonth,
|
||||
};
|
||||
}),
|
||||
getHistory: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
year: z.number().int(),
|
||||
month: z.number().int().min(1).max(12),
|
||||
hash: z.string().optional(),
|
||||
serverID: zServerId.optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getMyGeneral(ctx);
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
const targetProfileName = input.serverID ?? ctx.profile.name;
|
||||
const isCurrentProfile = targetProfileName === ctx.profile.name;
|
||||
|
||||
const isCurrent =
|
||||
isCurrentProfile &&
|
||||
worldState.currentYear === input.year && worldState.currentMonth === input.month;
|
||||
|
||||
const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month);
|
||||
const { globalHistory, globalAction } = isCurrentProfile
|
||||
? await buildLogs(ctx, input.year, input.month)
|
||||
: {
|
||||
globalHistory: [`<C>●</>${input.month}월: 기록 없음`],
|
||||
globalAction: [`<C>●</>${input.month}월: 기록 없음`],
|
||||
};
|
||||
|
||||
if (isCurrent) {
|
||||
const map = await loadPublicMap(ctx, false);
|
||||
@@ -260,7 +295,7 @@ export const yearbookRouter = router({
|
||||
|
||||
const row = await ctx.db.yearbookHistory.findFirst({
|
||||
where: {
|
||||
profileName: ctx.profile.name,
|
||||
profileName: targetProfileName,
|
||||
year: input.year,
|
||||
month: input.month,
|
||||
},
|
||||
@@ -285,4 +320,4 @@ export const yearbookRouter = router({
|
||||
}
|
||||
return { notModified: false, hash, data };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { GameApiContext } from '../context.js';
|
||||
|
||||
export const accessPages = [
|
||||
'front-info',
|
||||
'nation-info',
|
||||
'nation-cities',
|
||||
'global-info',
|
||||
'nation-list',
|
||||
'general-list',
|
||||
'current-city',
|
||||
'diplomacy',
|
||||
'nation-generals',
|
||||
'nation-personnel',
|
||||
'nation-finance',
|
||||
'battle-center',
|
||||
'board',
|
||||
'best-general',
|
||||
'hall-of-fame',
|
||||
'dynasty',
|
||||
'yearbook',
|
||||
'nation-betting',
|
||||
'traffic',
|
||||
'npc-list',
|
||||
'my-page',
|
||||
'npc-control',
|
||||
'tournament',
|
||||
'betting',
|
||||
] as const;
|
||||
|
||||
export type AccessPage = (typeof accessPages)[number];
|
||||
|
||||
export const accessPageWeights: Record<AccessPage, number> = {
|
||||
'front-info': 1,
|
||||
'nation-info': 1,
|
||||
'nation-cities': 1,
|
||||
'global-info': 1,
|
||||
'nation-list': 2,
|
||||
'general-list': 2,
|
||||
'current-city': 1,
|
||||
diplomacy: 1,
|
||||
'nation-generals': 1,
|
||||
'nation-personnel': 1,
|
||||
'nation-finance': 1,
|
||||
'battle-center': 1,
|
||||
board: 1,
|
||||
'best-general': 1,
|
||||
'hall-of-fame': 1,
|
||||
dynasty: 1,
|
||||
yearbook: 1,
|
||||
'nation-betting': 1,
|
||||
traffic: 1,
|
||||
'npc-list': 2,
|
||||
'my-page': 1,
|
||||
'npc-control': 1,
|
||||
tournament: 1,
|
||||
betting: 1,
|
||||
};
|
||||
|
||||
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readDate = (value: unknown): Date | null => {
|
||||
if (typeof value !== 'string' && !(value instanceof Date)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
export const resolveAccessWindows = (
|
||||
now: Date,
|
||||
tickSeconds: number,
|
||||
worldMeta: unknown
|
||||
): { dayStartedAt: Date; scoreStartedAt: Date } => {
|
||||
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const meta = asRecord(worldMeta);
|
||||
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
||||
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
||||
const scoreStartedAt =
|
||||
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
||||
? tickStartedAt
|
||||
: new Date(now.getTime() - fallbackTickMs);
|
||||
return { dayStartedAt, scoreStartedAt };
|
||||
};
|
||||
|
||||
export const upsertGeneralAccess = async (
|
||||
db: Pick<GameApiContext['db'], '$executeRaw'>,
|
||||
input: {
|
||||
generalId: number;
|
||||
userId: string;
|
||||
weight: number;
|
||||
now: Date;
|
||||
dayStartedAt: Date;
|
||||
scoreStartedAt: Date;
|
||||
}
|
||||
): Promise<void> => {
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
INSERT INTO general_access_log (
|
||||
general_id,
|
||||
user_id,
|
||||
last_refresh,
|
||||
refresh,
|
||||
refresh_total,
|
||||
refresh_score,
|
||||
refresh_score_total
|
||||
)
|
||||
VALUES (
|
||||
${input.generalId},
|
||||
${input.userId},
|
||||
${input.now},
|
||||
${input.weight},
|
||||
${input.weight},
|
||||
${input.weight},
|
||||
${input.weight}
|
||||
)
|
||||
ON CONFLICT (general_id) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
last_refresh = EXCLUDED.last_refresh,
|
||||
refresh = CASE
|
||||
WHEN general_access_log.last_refresh IS NULL
|
||||
OR general_access_log.last_refresh < ${input.dayStartedAt}
|
||||
THEN EXCLUDED.refresh
|
||||
ELSE general_access_log.refresh + EXCLUDED.refresh
|
||||
END,
|
||||
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
|
||||
refresh_score = CASE
|
||||
WHEN general_access_log.last_refresh IS NULL
|
||||
OR general_access_log.last_refresh < ${input.scoreStartedAt}
|
||||
THEN EXCLUDED.refresh_score
|
||||
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
|
||||
END,
|
||||
refresh_score_total =
|
||||
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
export const recordGeneralAccess = async (
|
||||
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||
page: AccessPage,
|
||||
now = new Date()
|
||||
): Promise<boolean> => {
|
||||
const user = ctx.auth?.user;
|
||||
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [general, worldState] = await Promise.all([
|
||||
ctx.db.general.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
ctx.db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: { tickSeconds: true, meta: true },
|
||||
}),
|
||||
]);
|
||||
if (!general || !worldState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = asRecord(worldState.meta);
|
||||
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
|
||||
const openTime = readDate(meta.opentime);
|
||||
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const weight = accessPageWeights[page];
|
||||
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||
|
||||
await upsertGeneralAccess(ctx.db, {
|
||||
generalId: general.id,
|
||||
userId: user.id,
|
||||
weight,
|
||||
now,
|
||||
dayStartedAt,
|
||||
scoreStartedAt,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
+24
-14
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
|
||||
|
||||
const t = initTRPC.context<GameApiContext>().create();
|
||||
|
||||
const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
auth: ctx.auth,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||
return next();
|
||||
@@ -46,17 +61,12 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.auth) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
auth: ctx.auth,
|
||||
},
|
||||
});
|
||||
});
|
||||
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||
|
||||
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||
export const sessionActivityProcedure = t.procedure;
|
||||
|
||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
|
||||
@@ -203,6 +203,9 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => {
|
||||
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
|
||||
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
|
||||
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
|
||||
generalMinimumGold: resolveNumber(constValues, ['generalMinimumGold'], 0),
|
||||
generalMinimumRice: resolveNumber(constValues, ['generalMinimumRice'], 500),
|
||||
npcSeizureMessageProb: resolveNumber(constValues, ['npcSeizureMessageProb'], 0.01),
|
||||
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { GamePrisma, 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: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
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: 'None',
|
||||
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 sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
|
||||
|
||||
const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
general?: GeneralRow | null;
|
||||
auctions?: Array<Record<string, unknown>>;
|
||||
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const requestCommand = vi.fn(async (command: { type: string }) => {
|
||||
if (command.type === 'auctionOpen') {
|
||||
return {
|
||||
type: 'auctionOpen' as const,
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'auctionBid' as const,
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
};
|
||||
});
|
||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||
const worldState = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
},
|
||||
},
|
||||
meta: { hiddenSeed: 'auction-hidden-seed' },
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
};
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) =>
|
||||
where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' }))
|
||||
),
|
||||
},
|
||||
auction: {
|
||||
findMany: vi.fn(async () => options.auctions ?? []),
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => worldState),
|
||||
},
|
||||
inheritancePoint: {
|
||||
findUnique: vi.fn(async () => ({ value: 10_000 })),
|
||||
},
|
||||
logEntry: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
zAdd: vi.fn(async () => 1),
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: redis as unknown 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, db, queryRaw, redis, requestCommand };
|
||||
};
|
||||
|
||||
describe('auction router actor and permission boundaries', () => {
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects reads and mutations when the authenticated user owns no general', async () => {
|
||||
const fixture = buildContext({
|
||||
auth: buildAuth('user-2'),
|
||||
general: buildGeneral({ userId: 'user-1' }),
|
||||
});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.auction.getOverview()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'General not found.',
|
||||
});
|
||||
await expect(
|
||||
caller.auction.openBuyRice({
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'General not found.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => {
|
||||
const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) });
|
||||
const input = {
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
generalId: 999,
|
||||
};
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.openBuyRice(input);
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionOpen',
|
||||
auctionType: 'BUY_RICE',
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts real unique-auction identities while preserving caller markers', async () => {
|
||||
const openedAt = new Date('2026-07-26T01:00:00Z');
|
||||
const fixture = buildContext({
|
||||
auctions: [
|
||||
{
|
||||
id: 31,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
hostGeneralId: 7,
|
||||
hostName: null,
|
||||
detail: { title: '칠성검 경매', startBidAmount: 5000 },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||
bids: [
|
||||
{
|
||||
id: 41,
|
||||
generalId: 88,
|
||||
amount: 5500,
|
||||
eventAt: openedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).auction.getOverview();
|
||||
const unique = result.uniqueAuctions[0];
|
||||
|
||||
expect(unique).toMatchObject({
|
||||
id: 31,
|
||||
hostGeneralId: null,
|
||||
isCallerHost: true,
|
||||
highestBid: { amount: 5500, isCaller: false },
|
||||
});
|
||||
expect(unique?.hostName).not.toBe('유비');
|
||||
expect(unique?.highestBid?.bidderName).not.toBe('관우');
|
||||
expect(JSON.stringify(unique)).not.toContain('"generalId"');
|
||||
expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7');
|
||||
});
|
||||
|
||||
it('keeps the legacy default of no requested close extension for a unique bid', async () => {
|
||||
const fixture = buildContext({
|
||||
queryRaw: async (query) => {
|
||||
const text = sqlText(query);
|
||||
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
hostGeneralId: 88,
|
||||
detail: { startBidAmount: 100, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('FROM auction_bid') && text.includes('general_id =')) {
|
||||
return [];
|
||||
}
|
||||
if (text.includes('SELECT bid.auction_id')) {
|
||||
return [{ auctionId: 31, generalId: 88, amount: 100 }];
|
||||
}
|
||||
if (text.includes('FROM auction_bid')) {
|
||||
return [{ id: 41, generalId: 88, amount: 100, meta: {} }];
|
||||
}
|
||||
if (text.includes('SELECT id, target_code')) {
|
||||
return [{ id: 31, targetCode: 'che_무기_12_칠성검' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.bidUnique({
|
||||
auctionId: 31,
|
||||
amount: 110,
|
||||
});
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveGameApiConfigFromEnv } from '../src/config.js';
|
||||
|
||||
describe('resolveGameApiConfigFromEnv', () => {
|
||||
it('keeps the deployment profile identity separate from the scenario id', () => {
|
||||
const config = resolveGameApiConfigFromEnv({
|
||||
PROFILE: 'hwe',
|
||||
SCENARIO: '1010',
|
||||
GAME_PROFILE_NAME: 'hwe:2',
|
||||
GAME_TOKEN_SECRET: 'test-secret',
|
||||
});
|
||||
|
||||
expect(config.profile).toBe('hwe');
|
||||
expect(config.scenario).toBe('1010');
|
||||
expect(config.profileName).toBe('hwe:2');
|
||||
});
|
||||
|
||||
it('falls back to the legacy profile and scenario pair', () => {
|
||||
const config = resolveGameApiConfigFromEnv({
|
||||
PROFILE: 'hwe',
|
||||
SCENARIO: '2',
|
||||
GAME_TOKEN_SECRET: 'test-secret',
|
||||
});
|
||||
|
||||
expect(config.profileName).toBe('hwe:2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
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 emperor = {
|
||||
id: 7,
|
||||
serverId: 'hwe_260725_fixture',
|
||||
phase: '훼2기',
|
||||
nationCount: '3 / 8',
|
||||
nationName: '촉, 위, 오',
|
||||
nationHist: '병가(2), 유가(1)',
|
||||
genCount: '7 / 21',
|
||||
personalHist: '의리(4)',
|
||||
specialHist: '상재(2)',
|
||||
name: '촉',
|
||||
type: 'che_병가',
|
||||
color: '#FF0000',
|
||||
year: 215,
|
||||
month: 4,
|
||||
power: 34434,
|
||||
gennum: 7,
|
||||
citynum: 8,
|
||||
pop: '12345 / 15000',
|
||||
poprate: '82.3 %',
|
||||
gold: 50000,
|
||||
rice: 60000,
|
||||
l12name: '유비',
|
||||
l12pic: '',
|
||||
l11name: '제갈량',
|
||||
l11pic: '',
|
||||
l10name: '관우',
|
||||
l10pic: '',
|
||||
l9name: '방통',
|
||||
l9pic: '',
|
||||
l8name: '장비',
|
||||
l8pic: '',
|
||||
l7name: '법정',
|
||||
l7pic: '',
|
||||
l6name: '조운',
|
||||
l6pic: '',
|
||||
l5name: '마량',
|
||||
l5pic: '',
|
||||
tiger: '관우【10】',
|
||||
eagle: '방통【7】',
|
||||
gen: '유비, 제갈량',
|
||||
history: ['<C>●</>촉이 천하를 통일'],
|
||||
aux: { winnerNationId: 1, privateNote: 'not-returned' },
|
||||
};
|
||||
|
||||
const oldNation = {
|
||||
id: 3,
|
||||
serverId: emperor.serverId,
|
||||
nation: 1,
|
||||
data: {
|
||||
nation: 1,
|
||||
name: '촉',
|
||||
color: '#FF0000',
|
||||
type: 'che_병가',
|
||||
level: 7,
|
||||
tech: 4000,
|
||||
power: 34434,
|
||||
maxCrew: 120000,
|
||||
maxCities: ['성도', '한중'],
|
||||
generals: [11, 12],
|
||||
history: ['<Y>유비</>가 황제로 즉위'],
|
||||
owner: 'not-returned',
|
||||
},
|
||||
date: new Date('2026-07-25T12:00:00.000Z'),
|
||||
};
|
||||
|
||||
const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (auth: GameSessionTokenPayload | null): GameApiContext => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
emperor: {
|
||||
findMany: async () => [emperor],
|
||||
findUnique: async ({ where }: { where: { id: number } }) => (where.id === emperor.id ? emperor : null),
|
||||
},
|
||||
oldNation: {
|
||||
findMany: async ({ where }: { where: { serverId: string } }) =>
|
||||
where.serverId === emperor.serverId ? [oldNation] : [],
|
||||
},
|
||||
oldGeneral: {
|
||||
findMany: async () => [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
},
|
||||
};
|
||||
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,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('dynasty public read model', () => {
|
||||
it('returns the current row and the complete legacy officer summary', async () => {
|
||||
const result = await appRouter.createCaller(buildContext(null)).dynasty.getList();
|
||||
|
||||
expect(result.current).toEqual({ year: 220, month: 1 });
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
serverId: 'hwe_260725_fixture',
|
||||
phase: '훼2기',
|
||||
name: '촉',
|
||||
l12name: '유비',
|
||||
l11name: '제갈량',
|
||||
l10name: '관우',
|
||||
l9name: '방통',
|
||||
l8name: '장비',
|
||||
l7name: '법정',
|
||||
l6name: '조운',
|
||||
l5name: '마량',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the same public DTO to anonymous, general owners and admins', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const owner = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const otherOwner = appRouter.createCaller(buildContext(authFor('owner-b')));
|
||||
const admin = appRouter.createCaller(buildContext(authFor('admin', ['admin'])));
|
||||
|
||||
const [anonymousList, ownerList, otherOwnerList, adminList] = await Promise.all([
|
||||
anonymous.dynasty.getList(),
|
||||
owner.dynasty.getList(),
|
||||
otherOwner.dynasty.getList(),
|
||||
admin.dynasty.getList(),
|
||||
]);
|
||||
expect(ownerList).toEqual(anonymousList);
|
||||
expect(otherOwnerList).toEqual(anonymousList);
|
||||
expect(adminList).toEqual(anonymousList);
|
||||
|
||||
const [anonymousDetail, ownerDetail, otherOwnerDetail, adminDetail] = await Promise.all([
|
||||
anonymous.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
owner.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
otherOwner.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
admin.dynasty.getDetail({ emperorId: emperor.id }),
|
||||
]);
|
||||
expect(ownerDetail).toEqual(anonymousDetail);
|
||||
expect(otherOwnerDetail).toEqual(anonymousDetail);
|
||||
expect(adminDetail).toEqual(anonymousDetail);
|
||||
});
|
||||
|
||||
it('returns only the legacy public archive fields and resolves general names', async () => {
|
||||
const result = await appRouter.createCaller(buildContext(authFor('owner-a'))).dynasty.getDetail({
|
||||
emperorId: emperor.id,
|
||||
});
|
||||
|
||||
expect(result.emperor).toEqual(
|
||||
expect.objectContaining({
|
||||
personalHist: '의리(4)',
|
||||
specialHist: '상재(2)',
|
||||
tiger: '관우【10】',
|
||||
eagle: '방통【7】',
|
||||
history: ['<C>●</>촉이 천하를 통일'],
|
||||
})
|
||||
);
|
||||
expect(result.nations).toEqual([
|
||||
expect.objectContaining({
|
||||
name: '촉',
|
||||
type: 'che_병가',
|
||||
typeName: '병가',
|
||||
levelName: '황제',
|
||||
maxPower: 34434,
|
||||
generalsFull: [
|
||||
{ generalNo: 11, name: '유비', lastYearMonth: 21504 },
|
||||
{ generalNo: 12, name: '제갈량', lastYearMonth: 21504 },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('privateNote');
|
||||
expect(JSON.stringify(result)).not.toContain('not-returned');
|
||||
expect(JSON.stringify(result)).not.toContain('"owner"');
|
||||
expect(JSON.stringify(result)).not.toContain('"data"');
|
||||
});
|
||||
|
||||
it('rejects invalid and missing record identifiers without querying another scope', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(null));
|
||||
|
||||
await expect(caller.dynasty.getDetail({ emperorId: 0 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
await expect(caller.dynasty.getDetail({ emperorId: 999 })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const generalId = 9_980_071;
|
||||
|
||||
integration('general access tracking persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
||||
const firstWindow = {
|
||||
generalId,
|
||||
userId: 'access-user-a',
|
||||
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||
};
|
||||
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, (_, index) =>
|
||||
upsertGeneralAccess(db, {
|
||||
...firstWindow,
|
||||
now: new Date(firstWindow.now.getTime() + index + 1),
|
||||
weight: 1,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-a',
|
||||
refresh: 22,
|
||||
refreshTotal: 22,
|
||||
refreshScore: 22,
|
||||
refreshScoreTotal: 22,
|
||||
});
|
||||
|
||||
await upsertGeneralAccess(db, {
|
||||
generalId,
|
||||
userId: 'access-user-b',
|
||||
now: new Date('2026-07-27T00:05:00.000Z'),
|
||||
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||
userId: 'access-user-b',
|
||||
refresh: 1,
|
||||
refreshTotal: 23,
|
||||
refreshScore: 1,
|
||||
refreshScoreTotal: 23,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { DatabaseClient } from '../src/context.js';
|
||||
|
||||
import { accessPageWeights, recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
|
||||
|
||||
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'access-session',
|
||||
user: {
|
||||
id: 'user-7',
|
||||
username: 'user7',
|
||||
displayName: '사용자7',
|
||||
roles,
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||
const findWorld = vi.fn(async () => ({
|
||||
tickSeconds: 600,
|
||||
meta: {
|
||||
opentime: '2026-07-25T00:00:00.000Z',
|
||||
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
||||
...meta,
|
||||
},
|
||||
}));
|
||||
const db = {
|
||||
$executeRaw: executeRaw,
|
||||
general: { findFirst: findGeneral },
|
||||
worldState: { findFirst: findWorld },
|
||||
} as unknown as DatabaseClient;
|
||||
return { db, executeRaw, findGeneral, findWorld };
|
||||
};
|
||||
|
||||
describe('general access tracking', () => {
|
||||
it('uses the legacy weight two for both global directory pages', () => {
|
||||
expect(accessPageWeights['nation-list']).toBe(2);
|
||||
expect(accessPageWeights['general-list']).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves the UTC day and latest processed turn windows', () => {
|
||||
expect(
|
||||
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||
scoreStartedAt: 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, findGeneral } = buildDb();
|
||||
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||
|
||||
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||
expect(findGeneral).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-7' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||
expect(statement.values).toEqual([
|
||||
7,
|
||||
'user-7',
|
||||
now,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
new Date('2026-07-26T00:00:00.000Z'),
|
||||
new Date('2026-07-26T03:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||
|
||||
const admin = buildDb();
|
||||
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, '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'))
|
||||
).resolves.toBe(false);
|
||||
expect(future.executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
const united = buildDb({ isUnited: 2 });
|
||||
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||
expect(united.executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
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';
|
||||
import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.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: '공개소유자', dex1: 120 },
|
||||
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: '빙의소유자', dex1: 80 },
|
||||
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: { dex1: 200 },
|
||||
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 },
|
||||
{ generalId: 1, type: 'dex1', value: 999 },
|
||||
{ generalId: 2, type: 'dex1', value: 999 },
|
||||
{ generalId: 3, type: 'dex1', value: 999 },
|
||||
],
|
||||
},
|
||||
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]);
|
||||
});
|
||||
|
||||
it('uses the general dex columns as the legacy source of truth instead of mirrored rank rows', async () => {
|
||||
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'user' });
|
||||
const dex = result.sections.find((section) => section.title === '보 병 숙 련 도');
|
||||
|
||||
expect(dex?.entries.map((entry) => [entry.id, entry.value, entry.printValue])).toEqual([
|
||||
[1, 120, '120'],
|
||||
[2, 80, '80'],
|
||||
]);
|
||||
expect(dex?.entries[0]).toMatchObject({
|
||||
bgColor: '#006400',
|
||||
fgColor: '#000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches PHP number_format rounding and the legacy fixed color table', () => {
|
||||
expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01');
|
||||
expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60');
|
||||
expect(resolveLegacyTextColor('#006400')).toBe('#000000');
|
||||
expect(resolveLegacyTextColor('#330000')).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
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 archiveServerId = 'hwe_260725_archive';
|
||||
const archiveRows = [
|
||||
{
|
||||
id: 1,
|
||||
profileName: archiveServerId,
|
||||
year: 200,
|
||||
month: 1,
|
||||
map: { year: 200, month: 1, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1000, cities: ['성도'] }],
|
||||
hash: 'archive-1',
|
||||
createdAt: new Date('2026-07-25T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
profileName: archiveServerId,
|
||||
year: 200,
|
||||
month: 2,
|
||||
map: { year: 200, month: 2, startYear: 190, cityList: [], nationList: [] },
|
||||
nations: [{ id: 1, name: '촉', color: '#FF0000', level: 7, power: 1200, cities: ['성도'] }],
|
||||
hash: 'archive-2',
|
||||
createdAt: new Date('2026-07-25T01:00:00.000Z'),
|
||||
},
|
||||
];
|
||||
|
||||
const authFor = (userId: string): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: profile.name,
|
||||
issuedAt: '2026-07-25T00:00:00.000Z',
|
||||
expiresAt: '2026-07-26T00:00:00.000Z',
|
||||
sessionId: `session-${userId}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: userId,
|
||||
displayName: userId,
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
const buildContext = (
|
||||
auth: GameSessionTokenPayload | null,
|
||||
options: { hasGeneral?: boolean } = {}
|
||||
): GameApiContext => {
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: async ({ where }: { where: { userId: string } }) =>
|
||||
options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId },
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({ currentYear: 220, currentMonth: 1 }),
|
||||
},
|
||||
yearbookHistory: {
|
||||
findFirst: async (args: {
|
||||
where: { profileName: string; year?: number; month?: number };
|
||||
orderBy?: Array<{ year?: 'asc' | 'desc'; month?: 'asc' | 'desc' }>;
|
||||
}) => {
|
||||
const candidates = archiveRows.filter(
|
||||
(row) =>
|
||||
row.profileName === args.where.profileName &&
|
||||
(args.where.year === undefined || row.year === args.where.year) &&
|
||||
(args.where.month === undefined || row.month === args.where.month)
|
||||
);
|
||||
if (!args.orderBy) {
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
const descending = args.orderBy[0]?.year === 'desc';
|
||||
return (descending ? candidates.at(-1) : candidates[0]) ?? null;
|
||||
},
|
||||
},
|
||||
};
|
||||
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,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('historical yearbook access from dynasty', () => {
|
||||
it('selects the archived server range without treating it as the live month', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const result = await caller.yearbook.getRange({ serverID: archiveServerId });
|
||||
|
||||
expect(result).toEqual({
|
||||
firstYearMonth: 200 * 12,
|
||||
lastYearMonth: 200 * 12 + 1,
|
||||
currentYearMonth: 200 * 12 + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the same archived public history for distinct general owners', async () => {
|
||||
const ownerA = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
const ownerB = appRouter.createCaller(buildContext(authFor('owner-b')));
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
ownerA.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
||||
ownerB.yearbook.getHistory({ serverID: archiveServerId, year: 200, month: 2 }),
|
||||
]);
|
||||
|
||||
expect(resultB).toEqual(resultA);
|
||||
expect(resultA).toMatchObject({
|
||||
notModified: false,
|
||||
data: {
|
||||
year: 200,
|
||||
month: 2,
|
||||
nations: [{ id: 1, name: '촉', cities: ['성도'] }],
|
||||
globalHistory: ['<C>●</>2월: 기록 없음'],
|
||||
globalAction: ['<C>●</>2월: 기록 없음'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires both an authenticated user and a user-owned general like legacy v_history.php', async () => {
|
||||
const anonymous = appRouter.createCaller(buildContext(null));
|
||||
const noGeneral = appRouter.createCaller(buildContext(authFor('owner-a'), { hasGeneral: false }));
|
||||
|
||||
await expect(anonymous.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(noGeneral.yearbook.getRange({ serverID: archiveServerId })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'General not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown archived server IDs instead of falling back to the live profile', async () => {
|
||||
const caller = appRouter.createCaller(buildContext(authFor('owner-a')));
|
||||
|
||||
await expect(caller.yearbook.getRange({ serverID: 'missing-server' })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
message: '연감 범위를 찾을 수 없습니다.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user