Fix KoN rating spam not caught by sanity system (#2130)

* Fix Knights of New Order rating spam not caught by sanity system

- Tighten voting rate limits (75→20/min, 4500→900/hr, 4510→1000 abuse)
- Immediate smite for severe under-rating on sanity checks (2+ levels off)
- Increase sanity check frequency (~2 per 20-image batch) with stratified
  selection biasing toward non-PG images
- Update fervor formula to penalize low accuracy (accuracy-weighted)
- Add periodic abuse detection job (6-hourly ClickHouse scan, Axiom logging)

NOTE: The Leaderboard table query for knights-new-order also needs a manual
update to match the new fervor formula.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Harden KoN anti-abuse: plug sanity check leaks, Redis-backed rate limits, Discord alerts

- Remove isSanityCheck flag and nsfwLevel from sanity check images in queue response
  so clients cannot distinguish them from regular images
- Detect sanity check images server-side in processImageRating and route transparently
- Remove public addSanityCheckRating tRPC endpoint (now internal only)
- Move rate limit config to Redis (NEW_ORDER.CONFIG key) to keep values out of public repo
- Add daily rate limit window alongside minute/hour
- Add mod endpoint (GET/PUT /api/mod/new-order/rate-limit-config) for managing limits
- Stop resetting sanity failure counter on smite cleanse (only reset on career reset)
- Add Discord webhook alerts for abuse detection job
- Fix logToAxiom calls using nonexistent 'new-order' datastream

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manuel Emilio Urena
2026-04-01 16:24:47 -04:00
committed by GitHub
parent 3b7029e131
commit 55954e1c56
9 changed files with 354 additions and 158 deletions
+1
View File
@@ -45,6 +45,7 @@ REDDIT_CLIENT_SECRET=
# Integrations
DISCORD_BOT_TOKEN=
DISCORD_GUILD_ID=
DISCORD_WEBHOOK_MOD_ALERTS=
# File uploading
S3_UPLOAD_KEY=REFER_TO_README
+1 -29
View File
@@ -335,35 +335,7 @@ export const useAddImageRating = (opts?: { filters?: GetImagesQueueSchema }) =>
},
});
const addSanityCheckRatingMutation = trpc.games.newOrder.addSanityCheckRating.useMutation({
onMutate: async (payload) => {
await queryUtils.games.newOrder.getImagesQueue.cancel();
queryUtils.games.newOrder.getImagesQueue.setData(opts?.filters, (old) => {
if (!old) return old;
return old.filter((image) => image.id !== payload.imageId);
});
},
onError: (error) => {
showErrorNotification({ title: 'Failed to send rating', error: new Error(error.message) });
},
});
const handleAddRating = (input: Omit<AddImageRatingInput, 'playerId'>) => {
// Check if this is a sanity check image
const prevQueue = queryUtils.games.newOrder.getImagesQueue.getData(opts?.filters);
const matchedImage = prevQueue?.find((image) => image.id === input.imageId);
const isSanityCheck = matchedImage?.metadata?.isSanityCheck === true;
if (isSanityCheck) {
// Use sanity check endpoint (only imageId and rating, no damnedReason)
return addSanityCheckRatingMutation.mutateAsync({
imageId: input.imageId,
rating: input.rating,
});
}
// Use regular rating endpoint
return addRatingMutation.mutateAsync(input);
};
@@ -378,7 +350,7 @@ export const useAddImageRating = (opts?: { filters?: GetImagesQueueSchema }) =>
return {
addRating: handleAddRating,
isLoading: addRatingMutation.isLoading || addSanityCheckRatingMutation.isLoading,
isLoading: addRatingMutation.isLoading,
skipRating: handleSkipImage,
};
};
+1
View File
@@ -52,6 +52,7 @@ export const serverSchema = z.object({
DISCORD_CLIENT_SECRET: z.string(),
DISCORD_BOT_TOKEN: z.string().optional(),
DISCORD_GUILD_ID: z.string().optional(),
DISCORD_WEBHOOK_MOD_ALERTS: z.string().optional(),
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
GOOGLE_CLIENT_ID: z.string(),
@@ -0,0 +1,37 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import * as z from 'zod';
import { REDIS_SYS_KEYS, sysRedis } from '~/server/redis/client';
import { ModEndpoint } from '~/server/utils/endpoint-helpers';
const configSchema = z.object({
perMinute: z.number().int().min(1).optional(),
perHour: z.number().int().min(1).optional(),
perDay: z.number().int().min(1).optional(),
abuseThreshold: z.number().int().min(1).optional(),
});
export default ModEndpoint(
async (req: NextApiRequest, res: NextApiResponse) => {
const key = REDIS_SYS_KEYS.NEW_ORDER.CONFIG;
if (req.method === 'GET') {
const config = await sysRedis.packed.get(key);
return res.status(200).json({ config: config ?? null });
}
// PUT — update config
const parsed = configSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid config', details: parsed.error.format() });
}
// Merge with existing config so partial updates work
const existing = (await sysRedis.packed.get(key)) ?? {};
const merged = { ...existing, ...parsed.data };
await sysRedis.packed.set(key, merged);
return res.status(200).json({ config: merged });
},
['GET', 'PUT']
);
+52 -38
View File
@@ -753,12 +753,29 @@ export const getImageRatingsCounter = (imageId: number) => {
return counter;
};
// Rate limiting configuration for voting
export const VOTING_RATE_LIMITS = {
perMinute: 75, // Max votes per minute
perHour: 4500, // Max votes per hour
abuseThreshold: 4510, // Auto-reset career threshold per hour
} as const;
type VotingRateLimitConfig = {
perMinute: number;
perHour: number;
perDay: number;
abuseThreshold: number;
};
const ALLOWED_RESPONSE = { allowed: true, remaining: 0, resetTime: 0, isAbuse: false } as const;
const MINUTE_WINDOW = 60 * 1000;
const HOUR_WINDOW = 60 * MINUTE_WINDOW;
const DAY_WINDOW = 24 * HOUR_WINDOW;
/**
* Get rate limit config from Redis. Returns null if unavailable — callers
* should skip rate limiting entirely when config is not set.
*/
async function getVotingRateLimitConfig(): Promise<VotingRateLimitConfig | null> {
try {
return await sysRedis.packed.get<VotingRateLimitConfig>(REDIS_SYS_KEYS.NEW_ORDER.CONFIG);
} catch {
return null;
}
}
// Simple sliding window rate limiter for voting
export async function checkVotingRateLimit(userId: number): Promise<{
@@ -767,63 +784,60 @@ export async function checkVotingRateLimit(userId: number): Promise<{
resetTime: number;
isAbuse: boolean;
}> {
if (!redis) {
return {
allowed: true,
remaining: VOTING_RATE_LIMITS.perMinute,
resetTime: Date.now() + 60000,
isAbuse: false,
};
}
if (!redis) return ALLOWED_RESPONSE;
const limits = await getVotingRateLimitConfig();
if (!limits) return ALLOWED_RESPONSE;
const now = Date.now();
const minuteKey = `${REDIS_KEYS.CACHES.NEW_ORDER.RATE_LIMIT.MINUTE}:${userId}` as const;
const hourKey = `${REDIS_KEYS.CACHES.NEW_ORDER.RATE_LIMIT.HOUR}:${userId}` as const;
const minuteWindow = 60 * 1000; // 1 minute
const hourWindow = 60 * 60 * 1000; // 1 hour
const dayKey = `${REDIS_KEYS.CACHES.NEW_ORDER.RATE_LIMIT.DAY}:${userId}` as const;
try {
// Clean up old entries
await redis.zRemRangeByScore(minuteKey, '-inf', now - minuteWindow);
await redis.zRemRangeByScore(hourKey, '-inf', now - hourWindow);
await Promise.all([
redis.zRemRangeByScore(minuteKey, '-inf', now - MINUTE_WINDOW),
redis.zRemRangeByScore(hourKey, '-inf', now - HOUR_WINDOW),
redis.zRemRangeByScore(dayKey, '-inf', now - DAY_WINDOW),
]);
// Count current requests
const [minuteCount, hourCount] = await Promise.all([
const [minuteCount, hourCount, dayCount] = await Promise.all([
redis.zCard(minuteKey),
redis.zCard(hourKey),
redis.zCard(dayKey),
]);
// Check limits
const minuteAllowed = minuteCount < VOTING_RATE_LIMITS.perMinute;
const hourAllowed = hourCount < VOTING_RATE_LIMITS.perHour;
const isAbuse = hourCount >= VOTING_RATE_LIMITS.abuseThreshold;
const allowed = minuteAllowed && hourAllowed && !isAbuse;
const minuteAllowed = minuteCount < limits.perMinute;
const hourAllowed = hourCount < limits.perHour;
const dayAllowed = dayCount < limits.perDay;
const isAbuse = hourCount >= limits.abuseThreshold;
const allowed = minuteAllowed && hourAllowed && dayAllowed && !isAbuse;
if (allowed) {
// Add current request
const requestId = `${now}-${Math.random()}`;
await Promise.all([
redis.zAdd(minuteKey, { score: now, value: requestId }),
redis.zAdd(hourKey, { score: now, value: requestId }),
redis.expire(minuteKey, 60),
redis.expire(hourKey, 3600),
]);
await redis
.multi()
.zAdd(minuteKey, { score: now, value: requestId })
.zAdd(hourKey, { score: now, value: requestId })
.zAdd(dayKey, { score: now, value: requestId })
.expire(minuteKey, 60)
.expire(hourKey, 3600)
.expire(dayKey, 86400)
.exec();
}
return {
allowed,
remaining: Math.max(0, VOTING_RATE_LIMITS.perMinute - minuteCount - (allowed ? 1 : 0)),
resetTime: now + minuteWindow,
remaining: Math.max(0, limits.perMinute - minuteCount - (allowed ? 1 : 0)),
resetTime: now + MINUTE_WINDOW,
isAbuse,
};
} catch (error) {
handleLogError(error as Error, `Rate limiting failed for user ${userId}`);
// Fallback to allow if Redis fails
return {
allowed: true,
remaining: VOTING_RATE_LIMITS.perMinute,
resetTime: now + 60000,
isAbuse: false,
};
return ALLOWED_RESPONSE;
}
}
+99 -1
View File
@@ -1,3 +1,4 @@
import { env } from '~/env/server';
import dayjs from '~/shared/utils/dayjs';
import { chunk } from 'lodash-es';
import { clickhouse } from '~/server/clickhouse/client';
@@ -17,7 +18,7 @@ import {
setActiveSlot,
} from '~/server/games/new-order/utils';
import { createJob } from '~/server/jobs/job';
import { TransactionType } from '~/shared/constants/buzz.constants';
import { logToAxiom } from '~/server/logging/client';
import { createBuzzTransactionMany } from '~/server/services/buzz.service';
import {
calculateFervor,
@@ -26,6 +27,7 @@ import {
processFinalRatings,
} from '~/server/services/games/new-order.service';
import { limitConcurrency } from '~/server/utils/concurrency-helpers';
import { TransactionType } from '~/shared/constants/buzz.constants';
import { NewOrderRankType } from '~/shared/utils/prisma/enums';
import { createLogger } from '~/utils/logging';
@@ -491,6 +493,101 @@ const newOrderChangeRateTarget = createJob(
}
);
// Periodic abuse detection: identify users with suspicious rating patterns
// Runs every 6 hours, logs to Axiom for monitoring
const newOrderAbuseDetection = createJob('new-order-abuse-detection', '0 23 * * *', async () => {
if (!clickhouse) return;
log('AbuseDetection :: Scanning for suspicious rating patterns');
const suspects = await clickhouse.$query<{
userId: number;
totalRatings: number;
uniqueRatings: number;
dominantRating: number;
dominantPct: number;
avgPerMinute: number;
}>`
WITH user_dominant AS (
SELECT
userId,
topK(1)(rating)[1] as dominantRating
FROM knights_new_order_image_rating
WHERE createdAt >= now() - INTERVAL 48 HOUR
AND rank != 'Acolyte'
GROUP BY userId
)
SELECT
r.userId,
count() as totalRatings,
uniq(r.rating) as uniqueRatings,
d.dominantRating,
countIf(r.rating = d.dominantRating) / count() * 100 as dominantPct,
count() / greatest(dateDiff('minute', min(r.createdAt), max(r.createdAt)), 1) as avgPerMinute
FROM knights_new_order_image_rating r
JOIN user_dominant d ON r.userId = d.userId
WHERE r.createdAt >= now() - INTERVAL 48 HOUR
AND r.rank != 'Acolyte'
GROUP BY r.userId, d.dominantRating
HAVING totalRatings >= 200
AND (uniqueRatings = 1 OR dominantPct >= 90 OR avgPerMinute > 15)
ORDER BY totalRatings DESC
LIMIT 50
`;
if (suspects.length > 0) {
log(`AbuseDetection :: Found ${suspects.length} suspicious users`);
await logToAxiom({
type: 'warning',
name: 'new-order-abuse-detection-scan',
details: {
suspectCount: suspects.length,
suspects: suspects.map((s) => ({
userId: s.userId,
totalRatings: s.totalRatings,
uniqueRatings: s.uniqueRatings,
dominantRating: s.dominantRating,
dominantPct: Math.round(s.dominantPct),
avgPerMinute: Math.round(s.avgPerMinute * 10) / 10,
})),
},
message: `Abuse detection scan found ${suspects.length} suspicious users in the last 48 hours`,
}).catch(() => null);
// Alert moderators via Discord webhook
if (env.DISCORD_WEBHOOK_MOD_ALERTS) {
const suspectLines = suspects
.slice(0, 10) // Cap at 10 to keep the embed manageable
.map(
(s) =>
`• **User ${s.userId}** — ${s.totalRatings} votes, ${s.uniqueRatings} unique rating(s), ` +
`${Math.round(s.dominantPct)}% same value, ${(
Math.round(s.avgPerMinute * 10) / 10
).toFixed(1)}/min`
)
.join('\n');
await fetch(env.DISCORD_WEBHOOK_MOD_ALERTS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [
{
title: `⚠️ KoN Abuse Detection — ${suspects.length} suspect(s)`,
description:
suspectLines +
(suspects.length > 10 ? `\n... and ${suspects.length - 10} more` : ''),
color: 0xff9800,
timestamp: new Date().toISOString(),
},
],
}),
}).catch(() => null);
}
} else {
log('AbuseDetection :: No suspicious users found');
}
});
export const newOrderJobs = [
newOrderGrantBlessedBuzz,
newOrderDailyReset,
@@ -498,4 +595,5 @@ export const newOrderJobs = [
// newOrderCleanupQueues,
newOrderChangeFillTarget,
newOrderChangeRateTarget,
newOrderAbuseDetection,
];
+2
View File
@@ -722,6 +722,7 @@ export const REDIS_SYS_KEYS = {
POOL: 'new-order:sanity-checks',
FAILURES: 'new-order:sanity-failures',
},
CONFIG: 'new-order:config',
PROCESSING: {
LAST_PROCESSED_AT: 'new-order:processing:last-processed-at',
BATCH_CUTOFF: 'new-order:processing:batch-cutoff',
@@ -858,6 +859,7 @@ export const REDIS_KEYS = {
RATE_LIMIT: {
MINUTE: 'new-order:rate-limit:minute',
HOUR: 'new-order:rate-limit:hour',
DAY: 'new-order:rate-limit:day',
},
},
TOP_EARNERS: 'packed:caches:top-earners',
-11
View File
@@ -5,7 +5,6 @@ import { env } from '~/env/server';
import { TransactionType } from '~/shared/constants/buzz.constants';
import {
addImageRatingSchema,
addSanityCheckRatingSchema,
cleanseSmiteSchema,
getHistorySchema,
getImageRatersSchema,
@@ -22,7 +21,6 @@ import {
import { createBuzzTransaction, refundTransaction } from '~/server/services/buzz.service';
import {
addImageRating,
addSanityCheckRating,
cleanseSmite,
getImageRaters,
getImagesQueue,
@@ -146,15 +144,6 @@ export const gamesRouter = router({
isModerator: ctx.user.isModerator,
})
),
addSanityCheckRating: guardedProcedure
.input(addSanityCheckRatingSchema)
.use(isFlagProtected('newOrderGame'))
.mutation(({ input, ctx }) =>
addSanityCheckRating({
...input,
playerId: ctx.user.id,
})
),
resetCareer: guardedProcedure
.use(isFlagProtected('newOrderGame'))
.mutation(({ ctx }) => resetPlayer({ playerId: ctx.user.id })),
+161 -79
View File
@@ -243,7 +243,6 @@ export async function cleanseAllSmites({
});
await smitesCounter.reset({ id: playerId });
await sanityCheckFailuresCounter.reset({ id: playerId });
if (data.count === 0) return; // Nothing done :shrug:
@@ -271,7 +270,6 @@ export async function cleanseSmite({ id, cleansedReason, playerId }: CleanseSmit
});
const smiteCount = await smitesCounter.decrement({ id: playerId });
await sanityCheckFailuresCounter.reset({ id: playerId });
await signalClient
.send({
@@ -349,18 +347,18 @@ async function processImageRating({
chTracker,
isModerator,
}: AddImageRatingInput & { playerId: number; chTracker?: Tracker; isModerator?: boolean }) {
// Check player existence
// Validate player and image existence before consuming rate limit budget
const player = await getPlayerById({ playerId });
if (!player) throw throwNotFoundError(`No player with id ${playerId}`);
// Check image existence
const image = await dbRead.image.findUnique({
where: { id: imageId },
select: { id: true, nsfwLevel: true, metadata: true },
});
if (!image) throw throwNotFoundError(`No image with id ${imageId}`);
// Skip rate limiting for moderators
// Rate limiting — before sanity check detection so that all votes
// (regular and sanity) are counted and rate-limited uniformly
if (!isModerator) {
const rateLimitResult = await withSpan('games:newOrder:rateLimit', () =>
checkVotingRateLimit(playerId)
@@ -368,21 +366,17 @@ async function processImageRating({
// If abuse threshold exceeded, reset player career
if (rateLimitResult.isAbuse) {
// Log abuse detection for monitoring
logToAxiom(
{
type: 'warning',
name: 'new-order-abuse-detection',
details: {
playerId,
imageId,
action: 'career-reset',
reason: 'excessive-voting',
},
message: `Player ${playerId} exceeded abuse threshold and was reset`,
logToAxiom({
type: 'warning',
name: 'new-order-abuse-detection',
details: {
playerId,
imageId,
action: 'career-reset',
reason: 'excessive-voting',
},
'new-order'
).catch(() => null);
message: `Player ${playerId} exceeded abuse threshold and was reset`,
}).catch(() => null);
await resetPlayer({
playerId,
@@ -396,22 +390,17 @@ async function processImageRating({
// Standard rate limiting
if (!rateLimitResult.allowed) {
// Log rate limit hits for monitoring (but only occasionally to avoid spam)
if (Math.random() < 0.1) {
// 10% sampling
logToAxiom(
{
type: 'info',
name: 'new-order-rate-limit',
details: {
playerId,
remaining: rateLimitResult.remaining,
resetTime: rateLimitResult.resetTime,
},
message: `Player ${playerId} hit rate limit`,
logToAxiom({
type: 'info',
name: 'new-order-rate-limit',
details: {
playerId,
remaining: rateLimitResult.remaining,
resetTime: rateLimitResult.resetTime,
},
'new-order'
).catch(() => null);
message: `Player ${playerId} hit rate limit`,
}).catch(() => null);
}
throw throwBadRequestError(
@@ -422,6 +411,37 @@ async function processImageRating({
}
}
// Intercept sanity check images — the client doesn't know which images are sanity checks,
// so we detect it server-side and route to the sanity check handler transparently
if (sysRedis) {
const exactMatch = await sysRedis.sIsMember(
REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL,
`${imageId}:${rating}`
);
let isSanityImage = !!exactMatch;
// Also check if imageId exists with ANY level (in case the rating is wrong)
if (!isSanityImage) {
const possibleLevels = [
NsfwLevel.PG,
NsfwLevel.PG13,
NsfwLevel.R,
NsfwLevel.X,
NsfwLevel.XXX,
];
const memberChecks = await Promise.all(
possibleLevels.map((level) =>
sysRedis.sIsMember(REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL, `${imageId}:${level}`)
)
);
isSanityImage = memberChecks.some(Boolean);
}
if (isSanityImage) {
await addSanityCheckRating({ playerId, imageId, rating });
return { stats: player.stats };
}
}
// Find which specific queue this image is in
const allowedRankTypes = isModerator
? (['Inquisitor', NewOrderRankType.Knight] as NewOrderHighRankType[])
@@ -507,26 +527,23 @@ async function processImageRating({
priority: 1,
});
logToAxiom(
{
type: 'info',
name: 'new-order-down-rating-escalated',
details: {
imageId,
currentLevel: image.nsfwLevel,
consensusLevel: consensus,
distance: Flags.distance(image.nsfwLevel, consensus),
voteCount: newVoteCount,
},
message: `Image ${imageId} down-rating consensus (${
image.nsfwLevel
} ${consensus}, distance ${Flags.distance(
image.nsfwLevel,
consensus
)}) escalated to Inquisitor queue`,
logToAxiom({
type: 'info',
name: 'new-order-down-rating-escalated',
details: {
imageId,
currentLevel: image.nsfwLevel,
consensusLevel: consensus,
distance: Flags.distance(image.nsfwLevel, consensus),
voteCount: newVoteCount,
},
'new-order'
).catch(() => null);
message: `Image ${imageId} down-rating consensus (${
image.nsfwLevel
} ${consensus}, distance ${Flags.distance(
image.nsfwLevel,
consensus
)}) escalated to Inquisitor queue`,
}).catch(() => null);
} else {
// Apply the consensus decision (same level, up-rating, or down-rating by 1 level)
if (consensus) {
@@ -1073,9 +1090,16 @@ export function calculateFervor({
correctJudgments: number;
allJudgments: number;
}) {
// New formula: number_of_ratings + (number_correct_ratings * 100)
// This rewards both activity (total ratings) and accuracy (correct ratings)
return allJudgments + correctJudgments * 100;
// Formula: correct_ratings * 100 * accuracy_ratio
// The accuracy multiplier penalizes spammers: a user with 20% accuracy gets only 20% of
// the fervor they would otherwise earn. Legitimate users with 80%+ correct barely notice.
// Floor at 0.1 to avoid zeroing out completely.
// Examples:
// Legitimate (500 total, 400 correct, 80%): 400 * 100 * 0.8 = 32,000
// Spammer (5000 total, 1000 correct, 20%): 1000 * 100 * 0.2 = 20,000
const accuracyRatio = allJudgments > 0 ? correctJudgments / allJudgments : 0;
const accuracyMultiplier = Math.max(0.1, accuracyRatio);
return Math.floor(correctJudgments * 100 * accuracyMultiplier);
}
export function calculateVoteWeight({ level, smites }: { level: number; smites: number }): number {
@@ -1135,15 +1159,30 @@ export async function getSanityCheckImage(): Promise<SanityCheck | null> {
}
}
export async function handleSanityCheckFailure(playerId: number, imageId: number) {
export async function handleSanityCheckFailure({
playerId,
imageId,
submittedRating,
correctNsfwLevel,
}: {
playerId: number;
imageId: number;
submittedRating: NsfwLevel;
correctNsfwLevel: NsfwLevel;
}) {
if (!sysRedis) return;
try {
// Increment failure counter (auto-expires after 24 hours from first failure)
const failureCount = await sanityCheckFailuresCounter.increment({ id: playerId });
if (failureCount === 1) {
// First failure - warning only
// Severe under-rating: rating content 2+ levels below its actual level (e.g., XXX→PG)
// Uses Flags.distance() for safe bitwise-flag comparison
const isSevereUnderRating =
submittedRating < correctNsfwLevel && Flags.distance(correctNsfwLevel, submittedRating) >= 2;
if (failureCount === 1 && !isSevereUnderRating) {
// First failure (non-severe) - warning only
await createNotification({
category: NotificationCategory.System,
type: 'new-order-sanity-warning',
@@ -1172,12 +1211,13 @@ export async function handleSanityCheckFailure(playerId: number, imageId: number
})
.catch((e) => handleLogError(e, 'signals:new-order-sanity-warning'));
} else {
// Additional failure - apply smite
// Severe under-rating OR additional failure - apply smite immediately
await smitePlayer({
playerId,
modId: -1, // System
reason:
'You failed another sanity check within 24 hours. A smite penalty has been applied, reducing your vote weight.',
reason: isSevereUnderRating
? 'You severely under-rated a sanity check image. A smite penalty has been applied.'
: 'You failed another sanity check within 24 hours. A smite penalty has been applied, reducing your vote weight.',
size: newOrderConfig.smiteSize * 10,
});
}
@@ -1215,8 +1255,25 @@ export async function addSanityCheckRating({
`${imageId}:${rating}`
);
// Handle failure if incorrect
if (!isCorrect) await handleSanityCheckFailure(playerId, imageId);
// Handle failure if incorrect — look up the correct level for penalty severity
if (!isCorrect) {
// Probe all possible NSFW levels via O(1) sIsMember checks instead of fetching the whole set
const possibleLevels = [NsfwLevel.PG, NsfwLevel.PG13, NsfwLevel.R, NsfwLevel.X, NsfwLevel.XXX];
const memberChecks = await Promise.all(
possibleLevels.map((level) =>
sysRedis.sIsMember(REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL, `${imageId}:${level}`)
)
);
const matchIndex = memberChecks.findIndex(Boolean);
const correctNsfwLevel = matchIndex >= 0 ? possibleLevels[matchIndex] : rating; // fallback to submitted rating (won't trigger severe penalty)
await handleSanityCheckFailure({
playerId,
imageId,
submittedRating: rating,
correctNsfwLevel,
});
}
// Return result (no XP, no stats update, no ClickHouse tracking)
return { isCorrect };
@@ -1277,6 +1334,7 @@ export async function resetPlayer({
// Reset all counters for player
await Promise.all([
smitesCounter.reset({ id: playerId }),
sanityCheckFailuresCounter.reset({ id: playerId }),
correctJudgmentsCounter.reset({ id: playerId }),
allJudgmentsCounter.reset({ id: playerId }),
expCounter.reset({ id: playerId }),
@@ -1478,7 +1536,7 @@ export async function getImagesQueue({
id: number;
url: string;
nsfwLevel?: number;
metadata: ImageMetadata & { isSanityCheck?: boolean };
metadata: ImageMetadata;
}> = [];
// Moderators can specify queueType to test different queues (Acolyte or Knight only)
@@ -1575,29 +1633,53 @@ export async function getImagesQueue({
const allSanityChecks = await sysRedis!.sMembers(REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL);
if (allSanityChecks && allSanityChecks.length > 0) {
// Randomly select N sanity checks
const selectedCheck = getRandom(allSanityChecks);
// Extract image IDs for batch DB query
const [sanityImageId, sanityImageNsfwLevel] = selectedCheck.split(':').map(Number);
// Insert sanity checks scaled to queue size (~2 per 20 images, ~10 per 100)
// At least 1 even if the queue is tiny, scaling up for larger fetches
const sanityCheckCount = Math.max(1, Math.ceil(finalImages.length / 10));
// Batch fetch image data from database
const sanityImageData = await dbRead.image.findUnique({
where: { id: sanityImageId },
// Stratify selection: bias toward non-PG sanity images to catch under-raters
const pgChecks: string[] = [];
const nonPgChecks: string[] = [];
for (const entry of allSanityChecks) {
const nsfwLevel = Number(entry.split(':')[1]);
if (nsfwLevel <= NsfwLevel.PG13) {
pgChecks.push(entry);
} else {
nonPgChecks.push(entry);
}
}
// Select with bias: at least 40% non-PG if available
const shuffledNonPg = shuffle(nonPgChecks);
const shuffledPg = shuffle(pgChecks);
const minNonPg = Math.min(Math.ceil(sanityCheckCount * 0.4), shuffledNonPg.length);
const selectedChecks = [
...shuffledNonPg.slice(0, minNonPg),
...shuffle([...shuffledNonPg.slice(minNonPg), ...shuffledPg]),
].slice(0, sanityCheckCount);
// Batch fetch image data for all selected sanity checks
const sanityImageIds = selectedChecks.map((entry) => Number(entry.split(':')[0]));
const sanityImagesData = await dbRead.image.findMany({
where: { id: { in: sanityImageIds } },
select: { id: true, url: true, metadata: true },
});
const sanityImageMap = new Map(sanityImagesData.map((img) => [img.id, img]));
for (const entry of selectedChecks) {
const [imageIdStr] = entry.split(':');
const sanityImageId = Number(imageIdStr);
const imageData = sanityImageMap.get(sanityImageId);
if (!imageData) continue;
if (sanityImageData) {
const sanityCheckImage = {
id: sanityImageId,
url: sanityImageData.url,
nsfwLevel: sanityImageNsfwLevel as NsfwLevel,
metadata: {
...(sanityImageData.metadata as ImageMetadata),
isSanityCheck: true,
} as ImageMetadata,
url: imageData.url,
nsfwLevel: undefined, // Never leak correct level to the client
metadata: imageData.metadata as ImageMetadata,
};
const randomPosition = Math.floor(Math.random() * finalImages.length);
const randomPosition = Math.floor(Math.random() * (finalImages.length + 1));
finalImages.splice(randomPosition, 0, sanityCheckImage);
}
}