Add RCON client + SendCurrency service (AtomCMS-faithful)

- 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).
This commit is contained in:
Simo committed 2026-06-27 16:37:42 +02:00
1 parent 443d908909
commit 8fca407f0f
6 files changed
+350

No files matched your search

+5
View File
@@ -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.
+67
View File
@@ -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" } },
]);
});
});
+127
View File
@@ -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<string, unknown> | null;
}
export type RconTransport = (payload: RconPayload) => Promise<boolean>;
/**
* 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<boolean> =>
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<string, unknown> | null = null): Promise<boolean> {
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;
+66
View File
@@ -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 } }),
);
});
});
+82
View File
@@ -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<Exclude<CurrencyName, "credits">, number> = {
duckets: 0,
diamonds: 5,
points: 101,
};
export interface CurrencyDb {
user: {
update(args: {
where: { id: number };
data: { credits: { increment: number } };
}): Promise<unknown>;
};
usersCurrency: {
upsert(args: {
where: { userId_type: { userId: number; type: number } };
update: { amount: { increment: number } };
create: { userId: number; type: number; amount: number };
}): Promise<unknown>;
};
}
export interface SendCurrencyDeps {
rcon: Pick<RconClient, "giveCredits" | "giveDuckets" | "giveDiamonds" | "givePointsGotw">;
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<boolean> {
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<boolean> {
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);
}
}