diff --git a/app/components/admin/UserStats.vue b/app/components/admin/UserStats.vue
new file mode 100644
index 00000000..aa8c4ddc
--- /dev/null
+++ b/app/components/admin/UserStats.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
{{ stat.label }}
+
{{ stat.current }}
+
→ {{ stat.next }}
+
+
+
+
+
+
+
{{ t('admin.users.stats.history') }}
+
+ -
+ {{ formatDate(a.createdAt) }}
+ ↑ {{ signed(a.uploadedDelta) }}
+ ↓ {{ signed(a.downloadedDelta) }}
+ {{ a.reason }}
+ {{ a.admin ?? t('admin.users.stats.deletedAdmin') }}
+
+
+
+
+
+
+
+
diff --git a/app/components/admin/Users.vue b/app/components/admin/Users.vue
index 458eb661..9c9ed5c0 100644
--- a/app/components/admin/Users.vue
+++ b/app/components/admin/Users.vue
@@ -38,97 +38,110 @@
-
-
- {{ u.username }}
-
-
- ID: {{ u.id.substring(0, 8) }}...
-
-
- IP: {{ u.lastIp }}
-
-
-
-
-
-
-
-
-
+
+
-
+ IP: {{ u.lastIp }}
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
([]);
const roles = ref([]);
+// User whose upload/download panel is open
+const statsOpen = ref(null);
// Load roles on mount
onMounted(async () => {
diff --git a/i18n/locales/de/app.json b/i18n/locales/de/app.json
index e5257ac9..2005db5b 100644
--- a/i18n/locales/de/app.json
+++ b/i18n/locales/de/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "Möchtest du die Sperre von {name} wirklich aufheben?",
"banFailed": "Benutzer konnte nicht gesperrt werden",
"unbanFailed": "Sperre konnte nicht aufgehoben werden",
- "assignRoleFailed": "Rolle konnte nicht zugewiesen werden"
+ "assignRoleFailed": "Rolle konnte nicht zugewiesen werden",
+ "stats": {
+ "open": "Ratio",
+ "uploaded": "Hochgeladen",
+ "downloaded": "Heruntergeladen",
+ "operation": "Hinzufügen oder abziehen",
+ "add": "Hinzufügen",
+ "remove": "Abziehen",
+ "unit": "Einheit",
+ "reason": "Grund",
+ "reasonPlaceholder": "Im Verlauf sichtbar, z. B. Ausgleich für den Tracker-Ausfall",
+ "apply": "Anwenden",
+ "history": "Frühere Korrekturen",
+ "deletedAdmin": "Gelöschter Admin",
+ "applied": "Summen von {name} aktualisiert",
+ "failed": "Summen konnten nicht aktualisiert werden"
+ }
},
"footer": {
"title": "Footer-Links",
diff --git a/i18n/locales/en/app.json b/i18n/locales/en/app.json
index 56a7d3b1..c6d43b1c 100644
--- a/i18n/locales/en/app.json
+++ b/i18n/locales/en/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "Are you sure you want to unban {name}?",
"banFailed": "Failed to ban user",
"unbanFailed": "Failed to unban user",
- "assignRoleFailed": "Failed to assign role"
+ "assignRoleFailed": "Failed to assign role",
+ "stats": {
+ "open": "Ratio",
+ "uploaded": "Uploaded",
+ "downloaded": "Downloaded",
+ "operation": "Add or remove",
+ "add": "Add",
+ "remove": "Remove",
+ "unit": "Unit",
+ "reason": "Reason",
+ "reasonPlaceholder": "Shown in the history, e.g. compensation for the tracker outage",
+ "apply": "Apply",
+ "history": "Previous corrections",
+ "deletedAdmin": "Deleted admin",
+ "applied": "Totals of {name} updated",
+ "failed": "Could not update the totals"
+ }
},
"footer": {
"title": "Footer links",
diff --git a/i18n/locales/es/app.json b/i18n/locales/es/app.json
index 5755a48b..f561b35f 100644
--- a/i18n/locales/es/app.json
+++ b/i18n/locales/es/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "¿Seguro que quieres desbanear a {name}?",
"banFailed": "No se pudo banear al usuario",
"unbanFailed": "No se pudo desbanear al usuario",
- "assignRoleFailed": "No se pudo asignar el rol"
+ "assignRoleFailed": "No se pudo asignar el rol",
+ "stats": {
+ "open": "Ratio",
+ "uploaded": "Subido",
+ "downloaded": "Descargado",
+ "operation": "Añadir o quitar",
+ "add": "Añadir",
+ "remove": "Quitar",
+ "unit": "Unidad",
+ "reason": "Motivo",
+ "reasonPlaceholder": "Visible en el historial, p. ej. compensación por la caída del tracker",
+ "apply": "Aplicar",
+ "history": "Correcciones anteriores",
+ "deletedAdmin": "Admin eliminado",
+ "applied": "Totales de {name} actualizados",
+ "failed": "No se pudieron actualizar los totales"
+ }
},
"footer": {
"title": "Enlaces del pie de página",
diff --git a/i18n/locales/fr/app.json b/i18n/locales/fr/app.json
index 1604192e..56663501 100644
--- a/i18n/locales/fr/app.json
+++ b/i18n/locales/fr/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "Voulez-vous vraiment débannir {name} ?",
"banFailed": "Impossible de bannir l'utilisateur",
"unbanFailed": "Impossible de débannir l'utilisateur",
- "assignRoleFailed": "Impossible d'attribuer le rôle"
+ "assignRoleFailed": "Impossible d'attribuer le rôle",
+ "stats": {
+ "open": "Ratio",
+ "uploaded": "Upload",
+ "downloaded": "Download",
+ "operation": "Ajouter ou retirer",
+ "add": "Ajouter",
+ "remove": "Retirer",
+ "unit": "Unité",
+ "reason": "Raison",
+ "reasonPlaceholder": "Visible dans l'historique, ex. compensation de la panne du tracker",
+ "apply": "Appliquer",
+ "history": "Corrections précédentes",
+ "deletedAdmin": "Admin supprimé",
+ "applied": "Totaux de {name} mis à jour",
+ "failed": "Impossible de mettre à jour les totaux"
+ }
},
"footer": {
"title": "Liens du footer",
diff --git a/i18n/locales/it/app.json b/i18n/locales/it/app.json
index faf42f6c..592beaa2 100644
--- a/i18n/locales/it/app.json
+++ b/i18n/locales/it/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "Vuoi davvero rimuovere il ban a {name}?",
"banFailed": "Ban dell'utente non riuscito",
"unbanFailed": "Rimozione del ban non riuscita",
- "assignRoleFailed": "Assegnazione del ruolo non riuscita"
+ "assignRoleFailed": "Assegnazione del ruolo non riuscita",
+ "stats": {
+ "open": "Ratio",
+ "uploaded": "Caricato",
+ "downloaded": "Scaricato",
+ "operation": "Aggiungi o togli",
+ "add": "Aggiungi",
+ "remove": "Togli",
+ "unit": "Unità",
+ "reason": "Motivo",
+ "reasonPlaceholder": "Visibile nello storico, es. compensazione per il guasto del tracker",
+ "apply": "Applica",
+ "history": "Correzioni precedenti",
+ "deletedAdmin": "Admin eliminato",
+ "applied": "Totali di {name} aggiornati",
+ "failed": "Impossibile aggiornare i totali"
+ }
},
"footer": {
"title": "Link del footer",
diff --git a/i18n/locales/ja/app.json b/i18n/locales/ja/app.json
index 787c52d0..f5ac42bc 100644
--- a/i18n/locales/ja/app.json
+++ b/i18n/locales/ja/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "{name} の BAN を解除してもよろしいですか?",
"banFailed": "ユーザーの BAN に失敗しました",
"unbanFailed": "ユーザーの BAN 解除に失敗しました",
- "assignRoleFailed": "ロールの割り当てに失敗しました"
+ "assignRoleFailed": "ロールの割り当てに失敗しました",
+ "stats": {
+ "open": "レシオ",
+ "uploaded": "アップロード量",
+ "downloaded": "ダウンロード量",
+ "operation": "追加または削減",
+ "add": "追加",
+ "remove": "削減",
+ "unit": "単位",
+ "reason": "理由",
+ "reasonPlaceholder": "履歴に表示されます(例:トラッカー障害の補填)",
+ "apply": "適用",
+ "history": "過去の修正",
+ "deletedAdmin": "削除された管理者",
+ "applied": "{name} の合計を更新しました",
+ "failed": "合計を更新できませんでした"
+ }
},
"footer": {
"title": "フッターのリンク",
diff --git a/i18n/locales/ru/app.json b/i18n/locales/ru/app.json
index cbacdb0e..99ea9335 100644
--- a/i18n/locales/ru/app.json
+++ b/i18n/locales/ru/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "Вы уверены, что хотите разбанить {name}?",
"banFailed": "Не удалось забанить пользователя",
"unbanFailed": "Не удалось разбанить пользователя",
- "assignRoleFailed": "Не удалось назначить роль"
+ "assignRoleFailed": "Не удалось назначить роль",
+ "stats": {
+ "open": "Рейтинг",
+ "uploaded": "Отдано",
+ "downloaded": "Скачано",
+ "operation": "Добавить или убрать",
+ "add": "Добавить",
+ "remove": "Убрать",
+ "unit": "Единица",
+ "reason": "Причина",
+ "reasonPlaceholder": "Видна в истории, например компенсация за сбой трекера",
+ "apply": "Применить",
+ "history": "Прошлые исправления",
+ "deletedAdmin": "Удалённый админ",
+ "applied": "Итоги {name} обновлены",
+ "failed": "Не удалось обновить итоги"
+ }
},
"footer": {
"title": "Ссылки в подвале",
diff --git a/i18n/locales/zh/app.json b/i18n/locales/zh/app.json
index 33ee422b..59159ae7 100644
--- a/i18n/locales/zh/app.json
+++ b/i18n/locales/zh/app.json
@@ -905,7 +905,23 @@
"confirmUnban": "确定要解封 {name} 吗?",
"banFailed": "封禁用户失败",
"unbanFailed": "解封用户失败",
- "assignRoleFailed": "分配角色失败"
+ "assignRoleFailed": "分配角色失败",
+ "stats": {
+ "open": "分享率",
+ "uploaded": "上传量",
+ "downloaded": "下载量",
+ "operation": "增加或减少",
+ "add": "增加",
+ "remove": "减少",
+ "unit": "单位",
+ "reason": "原因",
+ "reasonPlaceholder": "显示在历史记录中,例如 tracker 故障补偿",
+ "apply": "应用",
+ "history": "以往修正",
+ "deletedAdmin": "已删除的管理员",
+ "applied": "已更新 {name} 的总量",
+ "failed": "无法更新总量"
+ }
},
"footer": {
"title": "页脚链接",
diff --git a/server/api/admin/panic/encrypt.post.ts b/server/api/admin/panic/encrypt.post.ts
index adf40935..4cb1c633 100644
--- a/server/api/admin/panic/encrypt.post.ts
+++ b/server/api/admin/panic/encrypt.post.ts
@@ -13,6 +13,7 @@ import {
shoutMessages,
reports,
bonusTransactions,
+ statAdjustments,
bounties,
} from '../../../db/schema';
import { requireAdminSession } from '../../../utils/adminAuth';
@@ -256,6 +257,14 @@ export default defineEventHandler(async (event) => {
.where(eq(bonusTransactions.id, transaction.id));
}
+ const allAdjustments = await tx.select().from(statAdjustments);
+ for (const adjustment of allAdjustments) {
+ await tx
+ .update(statAdjustments)
+ .set({ reason: encryptField(adjustment.reason, key)! })
+ .where(eq(statAdjustments.id, adjustment.id));
+ }
+
// =====================================================================
// Save panic state
// encryptionIv stays null: each value carries its own IV (iv:ct:tag).
diff --git a/server/api/admin/panic/restore.post.ts b/server/api/admin/panic/restore.post.ts
index 6d8b14f4..3367cb5f 100644
--- a/server/api/admin/panic/restore.post.ts
+++ b/server/api/admin/panic/restore.post.ts
@@ -13,6 +13,7 @@ import {
shoutMessages,
reports,
bonusTransactions,
+ statAdjustments,
bounties,
} from '../../../db/schema';
import { protectEndpoint } from '../../../utils/rateLimit';
@@ -317,6 +318,14 @@ export default defineEventHandler(async (event) => {
.where(eq(bonusTransactions.id, transaction.id));
}
+ const allAdjustments = await tx.select().from(statAdjustments);
+ for (const adjustment of allAdjustments) {
+ await tx
+ .update(statAdjustments)
+ .set({ reason: decryptOrKeep(adjustment.reason, key, legacyIv)! })
+ .where(eq(statAdjustments.id, adjustment.id));
+ }
+
// ===================================================================
// Update panic state
// ===================================================================
diff --git a/server/api/admin/users/[id]/stats.get.ts b/server/api/admin/users/[id]/stats.get.ts
new file mode 100644
index 00000000..27d5087b
--- /dev/null
+++ b/server/api/admin/users/[id]/stats.get.ts
@@ -0,0 +1,40 @@
+import { desc, eq } from 'drizzle-orm';
+import { alias } from 'drizzle-orm/pg-core';
+import { db, schema } from '~~/server/db';
+import { requireAdminSession } from '~~/server/utils/adminAuth';
+import { uuidSchema, validateParam } from '~~/server/utils/schemas';
+
+const admins = alias(schema.users, 'admins');
+
+/**
+ * GET /api/admin/users/:id/stats
+ * Uploaded/downloaded totals and the last admin corrections (admin only)
+ */
+export default defineEventHandler(async (event) => {
+ await requireAdminSession(event);
+ const userId = validateParam(event, 'id', uuidSchema);
+
+ const [user] = await db
+ .select({ uploaded: schema.users.uploaded, downloaded: schema.users.downloaded })
+ .from(schema.users)
+ .where(eq(schema.users.id, userId))
+ .limit(1);
+ if (!user) throw createError({ statusCode: 404, message: 'User not found' });
+
+ const adjustments = await db
+ .select({
+ id: schema.statAdjustments.id,
+ uploadedDelta: schema.statAdjustments.uploadedDelta,
+ downloadedDelta: schema.statAdjustments.downloadedDelta,
+ reason: schema.statAdjustments.reason,
+ createdAt: schema.statAdjustments.createdAt,
+ admin: admins.username,
+ })
+ .from(schema.statAdjustments)
+ .leftJoin(admins, eq(admins.id, schema.statAdjustments.adminId))
+ .where(eq(schema.statAdjustments.userId, userId))
+ .orderBy(desc(schema.statAdjustments.createdAt))
+ .limit(20);
+
+ return { ...user, adjustments };
+});
diff --git a/server/api/admin/users/[id]/stats.post.ts b/server/api/admin/users/[id]/stats.post.ts
new file mode 100644
index 00000000..38f182a1
--- /dev/null
+++ b/server/api/admin/users/[id]/stats.post.ts
@@ -0,0 +1,61 @@
+import { randomUUID } from 'crypto';
+import { eq, sql } from 'drizzle-orm';
+import { db, schema } from '~~/server/db';
+import { requireAdminSession } from '~~/server/utils/adminAuth';
+import { uuidSchema, validateBody, validateParam } from '~~/server/utils/schemas';
+import { invalidateTrackerUsers } from '~~/server/tracker/client';
+import { statAdjustmentSchema } from '~~/shared/utils/statAdjust';
+
+/**
+ * POST /api/admin/users/:id/stats
+ * Add to or remove from a member's uploaded/downloaded totals, logged with
+ * the admin and the reason (admin only)
+ */
+export default defineEventHandler(async (event) => {
+ const session = await requireAdminSession(event);
+ const userId = validateParam(event, 'id', uuidSchema);
+ const { uploadedDelta, downloadedDelta, reason } = await validateBody(event, statAdjustmentSchema);
+
+ const result = await db.transaction(async (tx) => {
+ // Lock the row: the tracker adds its traffic as deltas too, so neither
+ // write is lost, and the logged deltas are the ones really applied
+ const [before] = await tx
+ .select({ uploaded: schema.users.uploaded, downloaded: schema.users.downloaded })
+ .from(schema.users)
+ .where(eq(schema.users.id, userId))
+ .for('update');
+ if (!before) throw createError({ statusCode: 404, message: 'User not found' });
+
+ const [after] = await tx
+ .update(schema.users)
+ .set({
+ uploaded: sql`greatest(0, ${schema.users.uploaded} + ${uploadedDelta})`,
+ downloaded: sql`greatest(0, ${schema.users.downloaded} + ${downloadedDelta})`,
+ })
+ .where(eq(schema.users.id, userId))
+ .returning({ uploaded: schema.users.uploaded, downloaded: schema.users.downloaded });
+
+ const applied = {
+ uploadedDelta: after!.uploaded - before.uploaded,
+ downloadedDelta: after!.downloaded - before.downloaded,
+ };
+ if (applied.uploadedDelta === 0 && applied.downloadedDelta === 0) {
+ throw createError({ statusCode: 400, message: 'Nothing to remove: the totals are already 0' });
+ }
+
+ await tx.insert(schema.statAdjustments).values({
+ id: randomUUID(),
+ userId,
+ adminId: session.user.id,
+ ...applied,
+ reason,
+ });
+
+ return { ...after!, ...applied };
+ });
+
+ // The tracker caches user rows (ratio checks) for a few seconds
+ invalidateTrackerUsers();
+
+ return result;
+});
diff --git a/server/db/migrations/0019_stat_adjustments.sql b/server/db/migrations/0019_stat_adjustments.sql
new file mode 100644
index 00000000..14cc922d
--- /dev/null
+++ b/server/db/migrations/0019_stat_adjustments.sql
@@ -0,0 +1,11 @@
+-- Admin corrections of uploaded/downloaded totals, with who and why
+CREATE TABLE IF NOT EXISTS "stat_adjustments" (
+ "id" text PRIMARY KEY NOT NULL,
+ "user_id" text NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
+ "admin_id" text REFERENCES "users"("id") ON DELETE SET NULL,
+ "uploaded_delta" bigint NOT NULL,
+ "downloaded_delta" bigint NOT NULL,
+ "reason" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+CREATE INDEX IF NOT EXISTS "stat_adjustments_user_idx" ON "stat_adjustments" ("user_id", "created_at");
diff --git a/server/db/schema.ts b/server/db/schema.ts
index 312f636d..564a077f 100644
--- a/server/db/schema.ts
+++ b/server/db/schema.ts
@@ -570,6 +570,26 @@ export const bonusTransactions = pgTable(
(table) => [index('bonus_tx_user_idx').on(table.userId, table.createdAt)]
);
+/**
+ * Admin corrections of a member's uploaded/downloaded totals, with who and why.
+ * Deltas are the amounts actually applied (totals never go below 0).
+ */
+export const statAdjustments = pgTable(
+ 'stat_adjustments',
+ {
+ id: text('id').primaryKey(),
+ userId: text('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ adminId: text('admin_id').references(() => users.id, { onDelete: 'set null' }),
+ uploadedDelta: bigint('uploaded_delta', { mode: 'number' }).notNull(),
+ downloadedDelta: bigint('downloaded_delta', { mode: 'number' }).notNull(),
+ reason: text('reason').notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ },
+ (table) => [index('stat_adjustments_user_idx').on(table.userId, table.createdAt)]
+);
+
export const shopItems = pgTable('shop_items', {
id: text('id').primaryKey(),
name: text('name').notNull(),
diff --git a/shared/utils/statAdjust.ts b/shared/utils/statAdjust.ts
new file mode 100644
index 00000000..ed94d0c1
--- /dev/null
+++ b/shared/utils/statAdjust.ts
@@ -0,0 +1,42 @@
+import { z } from 'zod';
+
+/**
+ * Admin corrections of a member's uploaded/downloaded totals (ratio fixes,
+ * compensation after a tracker outage, penalties).
+ */
+export const STAT_UNITS = {
+ MB: 1024 ** 2,
+ GB: 1024 ** 3,
+ TB: 1024 ** 4,
+} as const;
+export type StatUnit = keyof typeof STAT_UNITS;
+
+// 1 PiB either way: a typo, not a correction
+export const MAX_STAT_DELTA = 1024 ** 5;
+
+export const statAdjustmentSchema = z
+ .object({
+ uploadedDelta: z.number().int().min(-MAX_STAT_DELTA).max(MAX_STAT_DELTA),
+ downloadedDelta: z.number().int().min(-MAX_STAT_DELTA).max(MAX_STAT_DELTA),
+ reason: z.string().trim().min(1).max(200),
+ })
+ .refine((v) => v.uploadedDelta !== 0 || v.downloadedDelta !== 0, { message: 'Nothing to change' });
+export type StatAdjustmentInput = z.infer;
+
+/** Amount typed in the form, signed, to whole bytes */
+export function toBytes(amount: number, unit: StatUnit, remove = false): number {
+ if (!Number.isFinite(amount) || amount <= 0) return 0;
+ const bytes = Math.round(amount * STAT_UNITS[unit]);
+ return remove ? -bytes : bytes;
+}
+
+/** New total and the delta actually applied: totals never go below 0 */
+export function applyStatDelta(current: number, delta: number): { value: number; applied: number } {
+ const value = Math.max(0, current + delta);
+ return { value, applied: value - current };
+}
+
+/** Same rule as the dashboard: nothing downloaded is ∞ once something was uploaded */
+export function ratioOf(uploaded: number, downloaded: number): number {
+ return downloaded === 0 ? (uploaded > 0 ? Infinity : 0) : uploaded / downloaded;
+}
diff --git a/tests/stat-adjust.test.ts b/tests/stat-adjust.test.ts
new file mode 100644
index 00000000..7adf92ad
--- /dev/null
+++ b/tests/stat-adjust.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest';
+import { MAX_STAT_DELTA, applyStatDelta, ratioOf, statAdjustmentSchema, toBytes } from '../shared/utils/statAdjust';
+
+describe('toBytes', () => {
+ it('converts binary units and signs removals', () => {
+ expect(toBytes(1, 'GB')).toBe(1024 ** 3);
+ expect(toBytes(1.5, 'TB', true)).toBe(-1.5 * 1024 ** 4);
+ expect(toBytes(0.5, 'MB')).toBe(524288);
+ });
+
+ it('ignores empty, negative and invalid amounts', () => {
+ expect(toBytes(0, 'GB')).toBe(0);
+ expect(toBytes(-3, 'GB')).toBe(0);
+ expect(toBytes(Number.NaN, 'GB')).toBe(0);
+ });
+});
+
+describe('applyStatDelta', () => {
+ it('adds and removes', () => {
+ expect(applyStatDelta(100, 50)).toEqual({ value: 150, applied: 50 });
+ expect(applyStatDelta(100, -40)).toEqual({ value: 60, applied: -40 });
+ });
+
+ it('never goes below zero and reports what was really removed', () => {
+ expect(applyStatDelta(100, -500)).toEqual({ value: 0, applied: -100 });
+ expect(applyStatDelta(0, -1)).toEqual({ value: 0, applied: 0 });
+ });
+});
+
+describe('ratioOf', () => {
+ it('matches the dashboard rule', () => {
+ expect(ratioOf(0, 0)).toBe(0);
+ expect(ratioOf(10, 0)).toBe(Infinity);
+ expect(ratioOf(30, 20)).toBe(1.5);
+ });
+});
+
+describe('statAdjustmentSchema', () => {
+ const base = { uploadedDelta: 0, downloadedDelta: 1024, reason: 'Tracker outage' };
+
+ it('accepts a correction with a reason', () => {
+ expect(statAdjustmentSchema.safeParse(base).success).toBe(true);
+ });
+
+ it('rejects empty changes, missing reasons and absurd amounts', () => {
+ expect(statAdjustmentSchema.safeParse({ ...base, downloadedDelta: 0 }).success).toBe(false);
+ expect(statAdjustmentSchema.safeParse({ ...base, reason: ' ' }).success).toBe(false);
+ expect(statAdjustmentSchema.safeParse({ ...base, uploadedDelta: MAX_STAT_DELTA + 1 }).success).toBe(false);
+ expect(statAdjustmentSchema.safeParse({ ...base, uploadedDelta: 1.5 }).success).toBe(false);
+ });
+});