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),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user