From 8fca407f0f7212578f756c5930b472f409029e71 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 27 Jun 2026 16:37:42 +0200 Subject: [PATCH] Add RCON client + SendCurrency service (AtomCMS-faithful) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rcon.ts: Node net.Socket transport speaking AtomCMS's exact protocol (raw JSON {"key","data"} over rcon_ip:rcon_port, fire-and-forget) with a bounded timeout + backoff retry; typed commands matching RconService EXACTLY (sendgift `itemid`, disconnect `username`, no-data => data:null, givepoints numeric `type`, forwarduser, setmotto/setrank/alertuser, give*). Injectable transport keeps command shapes unit-testable. - send-currency.ts: mirrors the SendCurrency action — RCON deliver, else DB fallback (credits -> users.credits; duckets/diamonds/points -> users_currency by type 0/5/101). - env: RCON_HOST/PORT/TIMEOUT/RETRIES. vitest: SKIP_ENV_VALIDATION for unit tests. Verified: tsc exit 0, vitest 39/39, next build exit 0. Live TCP test deferred (needs a running emulator). --- src/env.ts | 5 + src/lib/services/rcon.test.ts | 67 +++++++++++++ src/lib/services/rcon.ts | 127 +++++++++++++++++++++++++ src/lib/services/send-currency.test.ts | 66 +++++++++++++ src/lib/services/send-currency.ts | 82 ++++++++++++++++ vitest.config.ts | 3 + 6 files changed, 350 insertions(+) create mode 100644 src/lib/services/rcon.test.ts create mode 100644 src/lib/services/rcon.ts create mode 100644 src/lib/services/send-currency.test.ts create mode 100644 src/lib/services/send-currency.ts diff --git a/src/env.ts b/src/env.ts index 3bab4f6a..4694dd09 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,11 @@ const schema = z.object({ DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(40), DATABASE_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(300_000), HOTEL_NAME: z.string().default("Atom"), + // RCON link to the Arcturus emulator (raw-JSON TCP protocol). + RCON_HOST: z.string().default("127.0.0.1"), + RCON_PORT: z.coerce.number().int().positive().default(3001), + RCON_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3), // NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing. AUTH_SECRET: z.string().min(1).optional(), // Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->argon2id. diff --git a/src/lib/services/rcon.test.ts b/src/lib/services/rcon.test.ts new file mode 100644 index 00000000..8cdd1dd3 --- /dev/null +++ b/src/lib/services/rcon.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { CurrencyType } from "./currency"; +import { RconClient, type RconPayload } from "./rcon"; + +function spyClient() { + const sent: RconPayload[] = []; + const client = new RconClient(async (p) => { + sent.push(p); + return true; + }); + return { client, sent }; +} + +describe("RconClient payloads (AtomCMS-faithful shapes)", () => { + it("givecredits", async () => { + const { client, sent } = spyClient(); + await client.giveCredits(7, 100); + expect(sent[0]).toEqual({ key: "givecredits", data: { user_id: 7, credits: 100 } }); + }); + + it("givepoints carries the numeric currency type", async () => { + const { client, sent } = spyClient(); + await client.giveDiamonds(7, 3); + expect(sent[0]).toEqual({ + key: "givepoints", + data: { user_id: 7, points: 3, type: CurrencyType.Diamonds }, + }); + expect(JSON.parse(JSON.stringify(sent[0])).data.type).toBe(5); + }); + + it("sendgift uses 'itemid' (not item_id)", async () => { + const { client, sent } = spyClient(); + await client.sendGift(7, 42); + expect(sent[0]).toEqual({ + key: "sendgift", + data: { user_id: 7, itemid: 42, message: "Here is a gift." }, + }); + }); + + it("disconnect carries username", async () => { + const { client, sent } = spyClient(); + await client.disconnectUser(7, "alice"); + expect(sent[0]).toEqual({ key: "disconnect", data: { user_id: 7, username: "alice" } }); + }); + + it("no-data commands send data:null", async () => { + const { client, sent } = spyClient(); + await client.updateWordFilter(); + await client.updateCatalog(); + expect(sent[0]).toEqual({ key: "updatewordfilter", data: null }); + expect(sent[1]).toEqual({ key: "updatecatalog", data: null }); + }); + + it("setmotto / setrank / forwarduser / alertuser shapes", async () => { + const { client, sent } = spyClient(); + await client.setMotto(1, "hi"); + await client.setRank(1, 7); + await client.forwardUser(1, 99); + await client.alertUser(1, "yo"); + expect(sent).toEqual([ + { key: "setmotto", data: { user_id: 1, motto: "hi" } }, + { key: "setrank", data: { user_id: 1, rank: 7 } }, + { key: "forwarduser", data: { user_id: 1, room_id: 99 } }, + { key: "alertuser", data: { user_id: 1, message: "yo" } }, + ]); + }); +}); diff --git a/src/lib/services/rcon.ts b/src/lib/services/rcon.ts new file mode 100644 index 00000000..28b84945 --- /dev/null +++ b/src/lib/services/rcon.ts @@ -0,0 +1,127 @@ +import net from "node:net"; +import { CurrencyType } from "@/lib/services/currency"; +import { env } from "@/env"; + +export interface RconPayload { + key: string; + data: Record | null; +} + +export type RconTransport = (payload: RconPayload) => Promise; + +/** + * TCP transport faithful to AtomCMS's RconService: writes the raw JSON + * `{"key":...,"data":...}` to rcon_ip:rcon_port with no terminator, fire-and- + * forget. The PHP original silently drops on a dead socket; we add a bounded + * connect timeout + exponential-backoff retry so delivery is best-effort. + */ +export function tcpTransport(opts: { + host: string; + port: number; + timeoutMs: number; + maxRetries: number; +}): RconTransport { + const sendOnce = (payload: RconPayload): Promise => + new Promise((resolve) => { + const socket = new net.Socket(); + const json = JSON.stringify(payload); + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(ok); + }; + const timer = setTimeout(() => finish(false), opts.timeoutMs); + socket.once("error", () => finish(false)); + socket.connect(opts.port, opts.host, () => { + socket.write(json, () => finish(true)); + }); + }); + + return async (payload) => { + for (let attempt = 0; attempt < opts.maxRetries; attempt++) { + if (await sendOnce(payload)) return true; + if (attempt < opts.maxRetries - 1) { + await new Promise((r) => setTimeout(r, 200 * 2 ** attempt)); + } + } + console.error("[RCON] delivery failed after retries:", payload.key); + return false; + }; +} + +/** + * Typed RCON commands. Payload shapes mirror AtomCMS's RconService EXACTLY + * (e.g. sendgift uses `itemid`, disconnect carries `username`, no-data commands + * send `data: null`, givepoints `type` is the numeric CurrencyType value). + */ +export class RconClient { + constructor(private readonly transport: RconTransport) {} + + send(key: string, data: Record | null = null): Promise { + return this.transport({ key, data }); + } + + giveCredits(userId: number, credits: number) { + return this.send("givecredits", { user_id: userId, credits }); + } + givePoints(userId: number, amount: number, type: CurrencyType) { + return this.send("givepoints", { user_id: userId, points: amount, type }); + } + giveDuckets(userId: number, amount: number) { + return this.givePoints(userId, amount, CurrencyType.Duckets); + } + giveDiamonds(userId: number, amount: number) { + return this.givePoints(userId, amount, CurrencyType.Diamonds); + } + givePointsGotw(userId: number, amount: number) { + return this.givePoints(userId, amount, CurrencyType.Points); + } + giveBadge(userId: number, badge: string) { + return this.send("givebadge", { user_id: userId, badge }); + } + setMotto(userId: number, motto: string) { + return this.send("setmotto", { user_id: userId, motto }); + } + setRank(userId: number, rank: number) { + return this.send("setrank", { user_id: userId, rank }); + } + sendGift(userId: number, itemId: number, message = "Here is a gift.") { + return this.send("sendgift", { user_id: userId, itemid: itemId, message }); + } + disconnectUser(userId: number, username: string) { + return this.send("disconnect", { user_id: userId, username }); + } + alertUser(userId: number, message: string) { + return this.send("alertuser", { user_id: userId, message }); + } + forwardUser(userId: number, roomId: number) { + return this.send("forwarduser", { user_id: userId, room_id: roomId }); + } + executeCommand(userId: number, command: string) { + return this.send("executecommand", { user_id: userId, command }); + } + updateWordFilter() { + return this.send("updatewordfilter"); + } + updateCatalog() { + return this.send("updatecatalog"); + } +} + +const globalForRcon = globalThis as unknown as { rcon?: RconClient }; + +export const rcon = + globalForRcon.rcon ?? + new RconClient( + tcpTransport({ + host: env.RCON_HOST, + port: env.RCON_PORT, + timeoutMs: env.RCON_TIMEOUT_MS, + maxRetries: env.RCON_MAX_RETRIES, + }), + ); + +if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon; diff --git a/src/lib/services/send-currency.test.ts b/src/lib/services/send-currency.test.ts new file mode 100644 index 00000000..fe776332 --- /dev/null +++ b/src/lib/services/send-currency.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { sendCurrency } from "./send-currency"; + +function deps(delivered: boolean) { + return { + rcon: { + giveCredits: vi.fn().mockResolvedValue(delivered), + giveDuckets: vi.fn().mockResolvedValue(delivered), + giveDiamonds: vi.fn().mockResolvedValue(delivered), + givePointsGotw: vi.fn().mockResolvedValue(delivered), + }, + db: { + user: { update: vi.fn().mockResolvedValue(undefined) }, + usersCurrency: { upsert: vi.fn().mockResolvedValue(undefined) }, + }, + }; +} + +describe("sendCurrency", () => { + it("rejects non-positive amounts", async () => { + const d = deps(true); + expect(await sendCurrency(d, 1, "credits", 0)).toBe(false); + expect(await sendCurrency(d, 1, "credits", null)).toBe(false); + expect(d.rcon.giveCredits).not.toHaveBeenCalled(); + }); + + it("delivers credits via RCON when connected (no DB write)", async () => { + const d = deps(true); + expect(await sendCurrency(d, 7, "credits", 100)).toBe(true); + expect(d.rcon.giveCredits).toHaveBeenCalledWith(7, 100); + expect(d.db.user.update).not.toHaveBeenCalled(); + }); + + it("falls back to users.credits when RCON fails", async () => { + const d = deps(false); + await sendCurrency(d, 7, "credits", 100); + expect(d.db.user.update).toHaveBeenCalledWith({ + where: { id: 7 }, + data: { credits: { increment: 100 } }, + }); + }); + + it("falls back to users_currency by type for diamonds", async () => { + const d = deps(false); + await sendCurrency(d, 7, "diamonds", 3); + expect(d.db.usersCurrency.upsert).toHaveBeenCalledWith({ + where: { userId_type: { userId: 7, type: 5 } }, + update: { amount: { increment: 3 } }, + create: { userId: 7, type: 5, amount: 3 }, + }); + }); + + it("uses the right type values (duckets=0, points=101)", async () => { + const d = deps(false); + await sendCurrency(d, 1, "duckets", 10); + await sendCurrency(d, 1, "points", 20); + expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ create: { userId: 1, type: 0, amount: 10 } }), + ); + expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ create: { userId: 1, type: 101, amount: 20 } }), + ); + }); +}); diff --git a/src/lib/services/send-currency.ts b/src/lib/services/send-currency.ts new file mode 100644 index 00000000..5c7378fd --- /dev/null +++ b/src/lib/services/send-currency.ts @@ -0,0 +1,82 @@ +import type { RconClient } from "@/lib/services/rcon"; + +export type CurrencyName = "credits" | "duckets" | "diamonds" | "points"; + +// users_currency.type values for the non-credits currencies. +const TYPE_VALUE: Record, number> = { + duckets: 0, + diamonds: 5, + points: 101, +}; + +export interface CurrencyDb { + user: { + update(args: { + where: { id: number }; + data: { credits: { increment: number } }; + }): Promise; + }; + usersCurrency: { + upsert(args: { + where: { userId_type: { userId: number; type: number } }; + update: { amount: { increment: number } }; + create: { userId: number; type: number; amount: number }; + }): Promise; + }; +} + +export interface SendCurrencyDeps { + rcon: Pick; + db: CurrencyDb; +} + +/** + * Mirrors AtomCMS's SendCurrency action: deliver via RCON, falling back to a + * direct DB write when RCON delivery fails (the PHP version checks + * rcon->isConnected up front; trying-then-falling-back is equivalent and more + * robust). Credits live on users.credits; duckets/diamonds/points live in + * users_currency keyed by (user_id, type). + */ +export async function sendCurrency( + deps: SendCurrencyDeps, + userId: number, + type: CurrencyName, + amount: number | null | undefined, +): Promise { + if (!amount || amount <= 0) return false; + + if (await rconDeliver(deps.rcon, userId, type, amount)) return true; + + if (type === "credits") { + await deps.db.user.update({ + where: { id: userId }, + data: { credits: { increment: amount } }, + }); + } else { + const t = TYPE_VALUE[type]; + await deps.db.usersCurrency.upsert({ + where: { userId_type: { userId, type: t } }, + update: { amount: { increment: amount } }, + create: { userId, type: t, amount }, + }); + } + return true; +} + +function rconDeliver( + rcon: SendCurrencyDeps["rcon"], + userId: number, + type: CurrencyName, + amount: number, +): Promise { + switch (type) { + case "credits": + return rcon.giveCredits(userId, amount); + case "duckets": + return rcon.giveDuckets(userId, amount); + case "diamonds": + return rcon.giveDiamonds(userId, amount); + case "points": + return rcon.givePointsGotw(userId, amount); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 3372a600..7b2407aa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,5 +11,8 @@ export default defineConfig({ }, test: { environment: "node", + // Modules under test transitively import @/env; skip its strict parse so + // unit tests run without a populated .env. + env: { SKIP_ENV_VALIDATION: "1" }, }, });