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:
1 parent
443d908909
commit
8fca407f0f
6 files changed
+350
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user