Merge branch 'main' into audit/lint-test-baseline-20260726
# Conflicts: # app/game-api/src/battleSim/worker.ts # app/game-api/src/router/general/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;
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,7 +2,6 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
@@ -38,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,
|
||||
};
|
||||
};
|
||||
@@ -263,28 +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 GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -42,6 +42,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 +171,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;
|
||||
@@ -222,6 +250,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,8 +2,11 @@ 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_FG_COLOR = '#ffffff';
|
||||
@@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => {
|
||||
|
||||
const percentText = (value: number): string => `${(value * 100).toFixed(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 +60,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;
|
||||
@@ -76,6 +79,7 @@ export const rankingRouter = router({
|
||||
userId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
meta: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
horseCode: true,
|
||||
@@ -185,7 +189,7 @@ export const rankingRouter = router({
|
||||
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,
|
||||
@@ -217,46 +221,91 @@ 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;
|
||||
return {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
nationName: nation?.name ?? '재야',
|
||||
bgColor: nation?.color ?? DEFAULT_BG_COLOR,
|
||||
fgColor: DEFAULT_FG_COLOR,
|
||||
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: DEFAULT_FG_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,6 +388,10 @@ 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),
|
||||
|
||||
+20
-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,8 @@ 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);
|
||||
|
||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||
|
||||
@@ -203,6 +203,8 @@ 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),
|
||||
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,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,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,248 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const profile: GameProfile = {
|
||||
id: 'che',
|
||||
scenario: 'default',
|
||||
name: 'che:default',
|
||||
};
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'ranking-session',
|
||||
user: {
|
||||
id: 'request-user-id',
|
||||
username: 'ranking-user',
|
||||
displayName: '조회자',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const generalRows = [
|
||||
{
|
||||
id: 1,
|
||||
name: '유비',
|
||||
nationId: 1,
|
||||
userId: 'private-user-id-1',
|
||||
npcState: 0,
|
||||
picture: '1.jpg',
|
||||
imageServer: 0,
|
||||
meta: { ownerName: '공개소유자' },
|
||||
experience: 1200,
|
||||
dedication: 900,
|
||||
horseCode: 'che_명마_15_적토마',
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
itemCode: 'None',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '빙의관우',
|
||||
nationId: 1,
|
||||
userId: 'private-user-id-2',
|
||||
npcState: 1,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
meta: { owner_name: '빙의소유자' },
|
||||
experience: 1100,
|
||||
dedication: 800,
|
||||
horseCode: 'None',
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
itemCode: 'None',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'NPC조조',
|
||||
nationId: 2,
|
||||
userId: null,
|
||||
npcState: 2,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
meta: {},
|
||||
experience: 1300,
|
||||
dedication: 1000,
|
||||
horseCode: 'None',
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
itemCode: 'None',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const buildContext = (options?: {
|
||||
authenticated?: boolean;
|
||||
isUnited?: boolean;
|
||||
includeOwnerDisplayName?: boolean;
|
||||
}): GameApiContext => {
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
meta: { isUnited: options?.isUnited ? 1 : 0 },
|
||||
config: {
|
||||
const: {
|
||||
allItems: {
|
||||
horse: { che_명마_15_적토마: 2 },
|
||||
weapon: {},
|
||||
book: {},
|
||||
item: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
nation: {
|
||||
findMany: async () => [
|
||||
{ id: 1, name: '촉', color: '#006400' },
|
||||
{ id: 2, name: '위', color: '#8b0000' },
|
||||
],
|
||||
},
|
||||
general: {
|
||||
findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) =>
|
||||
generalRows.filter((general) =>
|
||||
args.where.npcState.gte !== undefined
|
||||
? general.npcState >= args.where.npcState.gte
|
||||
: general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY)
|
||||
),
|
||||
},
|
||||
rankData: {
|
||||
findMany: async () => [
|
||||
{ generalId: 1, type: 'firenum', value: 10 },
|
||||
{ generalId: 2, type: 'firenum', value: 20 },
|
||||
{ generalId: 3, type: 'firenum', value: 30 },
|
||||
],
|
||||
},
|
||||
auction: {
|
||||
findMany: async () => [{ targetCode: 'che_명마_15_적토마' }],
|
||||
},
|
||||
gameHistory: {
|
||||
findMany: async () => [
|
||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||
{ season: 3, scenario: 22, scenarioName: '가상모드22' },
|
||||
],
|
||||
},
|
||||
hallOfFame: {
|
||||
findMany: async (args: { where: { type: string } }) =>
|
||||
args.where.type === 'experience'
|
||||
? [
|
||||
{
|
||||
generalNo: 1,
|
||||
value: 1200,
|
||||
aux: {
|
||||
name: '유비',
|
||||
ownerName: 'private-hall-user-id',
|
||||
...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}),
|
||||
nationName: '촉',
|
||||
bgColor: '#006400',
|
||||
fgColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
} as unknown as RedisConnector['client'];
|
||||
|
||||
return {
|
||||
db: db as unknown as DatabaseClient,
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile,
|
||||
auth: options?.authenticated === false ? null : auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
redis,
|
||||
accessTokenStore: new RedisAccessTokenStore(redis, profile.name),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
describe('ranking.getBestGeneral', () => {
|
||||
it('requires a game login even though the ranking is the same for every authenticated user', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => {
|
||||
const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({
|
||||
view: 'user',
|
||||
});
|
||||
|
||||
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]);
|
||||
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]);
|
||||
expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([
|
||||
expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }),
|
||||
expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('private-user-id');
|
||||
});
|
||||
|
||||
it('uses display names only after unification and preserves configured item copies plus auctions', async () => {
|
||||
const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({
|
||||
view: 'user',
|
||||
});
|
||||
|
||||
expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']);
|
||||
expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
itemKey: 'che_명마_15_적토마',
|
||||
owner: expect.objectContaining({ id: 1, name: '유비' }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
itemKey: 'che_명마_15_적토마',
|
||||
owner: expect.objectContaining({ id: 0, name: '경매중' }),
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('private-user-id');
|
||||
});
|
||||
|
||||
it('separates autonomous NPCs from users and possessed generals', async () => {
|
||||
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
|
||||
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ranking hall of fame', () => {
|
||||
it('remains public and groups scenario counts', async () => {
|
||||
const options = await appRouter
|
||||
.createCaller(buildContext({ authenticated: false }))
|
||||
.ranking.getHallOfFameOptions();
|
||||
expect(options).toEqual([
|
||||
{
|
||||
season: 3,
|
||||
scenarios: [{ id: 22, name: '가상모드22', count: 2 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an explicit display name but never exposes the stored account identifier', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true }))
|
||||
.ranking.getHallOfFame({ season: 3 });
|
||||
expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자');
|
||||
expect(JSON.stringify(result)).not.toContain('private-hall-user-id');
|
||||
|
||||
const redacted = await appRouter
|
||||
.createCaller(buildContext({ authenticated: false }))
|
||||
.ranking.getHallOfFame({ season: 3 });
|
||||
expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user