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

+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);
}
}