feat(arma): sync in-game shop money events to bank accounts
Route the game's `money.delta` events into the web banking system so a player's personal bank account tracks their in-game wallet. - New `processMoneyEvent` (src/lib/arma-bridge/money.ts) applies a parsed money delta to the player's personal account: debits go to the unit treasury (or leave if no treasury), refunds hit the treasury first and fall back to a plain deposit, and rewards/grants deposit directly. - Wire the handler into ArmaSyncEvents and add a `POST /api/arma/v1/balances` route (get_balances) that returns personal balances for Steam IDs, omitting unlinked IDs so the game keeps its own stored value. - Events dedupe by serverId+eventId so a retried delivery never double-charges, and the whole path never throws.
This commit is contained in:
parent
affc899da0
commit
73cae3defd
4 changed files with 340 additions and 0 deletions
30
src/app/api/arma/v1/balances/route.ts
Normal file
30
src/app/api/arma/v1/balances/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||||
|
import { getBalancesBySteamId } from "@/lib/arma-bridge/money";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the Arma 3 bridge (get_balances) when a player connects and on a
|
||||||
|
* ~60 s poll for online players. Returns personal bank account balances for
|
||||||
|
* the requested Steam IDs; unlinked Steam IDs are omitted so the game keeps
|
||||||
|
* its own stored balance for them.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const auth = await requireArmaServer(req);
|
||||||
|
if (auth instanceof NextResponse) return auth;
|
||||||
|
const { payload } = auth;
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => null)) as { steamIds?: unknown } | null;
|
||||||
|
const steamIds = body?.steamIds;
|
||||||
|
if (!Array.isArray(steamIds) || steamIds.length > 200 || steamIds.some((id) => typeof id !== "string")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "steamIds must be an array of at most 200 strings." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await getBalancesBySteamId(payload, steamIds as string[]);
|
||||||
|
return NextResponse.json({ ok: true, balances });
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents";
|
||||||
import { processBoxEvent } from "@/lib/arma-bridge/crates";
|
import { processBoxEvent } from "@/lib/arma-bridge/crates";
|
||||||
|
import { processMoneyEvent } from "@/lib/arma-bridge/money";
|
||||||
import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
|
import { isOperationLedgerEnabled } from "@/lib/operations/rollout";
|
||||||
import { processOperationEvent } from "@/lib/operations/process";
|
import { processOperationEvent } from "@/lib/operations/process";
|
||||||
|
|
||||||
|
|
@ -33,6 +34,12 @@ export const ArmaSyncEvents: CollectionConfig = {
|
||||||
req.payload.logger.error(`[CrateSync] Failed to process box event ${doc.eventId}: ${err}`);
|
req.payload.logger.error(`[CrateSync] Failed to process box event ${doc.eventId}: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (doc.type === "money.delta") {
|
||||||
|
// Awaited so that by the time the game's flush returns, the bank
|
||||||
|
// balance already reflects it (the game pulls balances right after
|
||||||
|
// flushing). processMoneyEvent never throws.
|
||||||
|
await processMoneyEvent(req.payload, doc);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
164
src/lib/arma-bridge/money.ts
Normal file
164
src/lib/arma-bridge/money.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { BankAccount } from "@/payload-types";
|
||||||
|
import { applyTransaction, ensurePersonalAccount, getTreasuryAccountId } from "@/lib/banking";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-game shop money <-> personal bank account sync (Arma bridge).
|
||||||
|
*
|
||||||
|
* The game queues a `money.delta` event for every balance change it makes;
|
||||||
|
* the events route dedupes by `serverId:eventId`, so a retried delivery never
|
||||||
|
* double-charges. Money flow (agreed economy rules):
|
||||||
|
* - purchase (negative) -> personal account pays the unit treasury
|
||||||
|
* - other negative (dialogue/Zeus) -> personal account pays the unit treasury
|
||||||
|
* - refund (positive) -> treasury pays the personal account back
|
||||||
|
* (created new if the treasury can't cover it)
|
||||||
|
* - reward / grant (positive) -> created new (plain deposit)
|
||||||
|
* Players whose Steam ID is not linked to a web user keep a game-only balance:
|
||||||
|
* their events are stored as evidence and skipped here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MoneyDelta {
|
||||||
|
steamId: string;
|
||||||
|
delta: number;
|
||||||
|
reason: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload shape from ptf_shop_fnc_setMoney: [steamId, delta, reason, detail]. */
|
||||||
|
export function parseMoneyDelta(payload: unknown): MoneyDelta | null {
|
||||||
|
if (!Array.isArray(payload) || payload.length < 3) return null;
|
||||||
|
const steamId = typeof payload[0] === "string" ? payload[0].trim() : "";
|
||||||
|
const rawDelta = payload[1];
|
||||||
|
const delta = typeof rawDelta === "number" && Number.isFinite(rawDelta) ? Math.round(rawDelta) : 0;
|
||||||
|
const reason = typeof payload[2] === "string" ? payload[2].trim().toLowerCase() : "";
|
||||||
|
const detail = typeof payload[3] === "string" ? payload[3].slice(0, 200) : "";
|
||||||
|
if (!steamId || steamId.length > 64 || delta === 0 || !reason) return null;
|
||||||
|
return { steamId, delta, reason, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUserIdBySteamId(payload: Payload, steamId: string): Promise<number | null> {
|
||||||
|
const users = await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
where: { steamId: { equals: steamId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return users.docs[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies one `money.delta` event to the player's personal account. Never
|
||||||
|
* throws: a failure (unlinked player, insufficient funds, frozen account) is
|
||||||
|
* logged, and the game's next balance pull corrects its displayed value.
|
||||||
|
*/
|
||||||
|
export async function processMoneyEvent(
|
||||||
|
payload: Payload,
|
||||||
|
raw: { eventId: string; payload: unknown },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const money = parseMoneyDelta(raw.payload);
|
||||||
|
if (!money) {
|
||||||
|
payload.logger.warn(`[ShopMoney] Money event ${raw.eventId} has a malformed payload; skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await findUserIdBySteamId(payload, money.steamId);
|
||||||
|
if (!userId) {
|
||||||
|
payload.logger.info(
|
||||||
|
`[ShopMoney] Steam ID ${money.steamId} is not linked to a user; game-only balance, event ${raw.eventId} skipped`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await ensurePersonalAccount(payload, userId);
|
||||||
|
const treasuryId = await getTreasuryAccountId(payload);
|
||||||
|
const amount = Math.abs(money.delta);
|
||||||
|
const memo = `In-game ${money.reason}${money.detail ? `: ${money.detail}` : ""} [${raw.eventId}]`;
|
||||||
|
|
||||||
|
if (money.delta < 0) {
|
||||||
|
// Money leaving the player goes to the unit treasury (like NPC vendor
|
||||||
|
// sales in the web market); without a treasury it simply leaves.
|
||||||
|
await applyTransaction(
|
||||||
|
payload,
|
||||||
|
treasuryId
|
||||||
|
? { type: "payment", fromAccountId: account.id, toAccountId: treasuryId, amount, memo }
|
||||||
|
: { type: "withdrawal", fromAccountId: account.id, amount, memo },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (money.reason === "refund" && treasuryId) {
|
||||||
|
try {
|
||||||
|
await applyTransaction(payload, {
|
||||||
|
type: "transfer",
|
||||||
|
fromAccountId: treasuryId,
|
||||||
|
toAccountId: account.id,
|
||||||
|
amount,
|
||||||
|
memo,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// A refund must never be lost: fall through to a plain deposit.
|
||||||
|
payload.logger.warn(`[ShopMoney] Treasury could not cover refund ${raw.eventId} (${err}); depositing instead`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyTransaction(payload, { type: "deposit", toAccountId: account.id, amount, memo });
|
||||||
|
} catch (err) {
|
||||||
|
payload.logger.error(`[ShopMoney] Failed to apply money event ${raw.eventId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal account balances for the given Steam IDs. Only linked players are
|
||||||
|
* returned (a linked player without an account yet reads as 0); unlinked
|
||||||
|
* Steam IDs are omitted so the game falls back to its own stored value.
|
||||||
|
* Read-only: accounts are created lazily by the first money event.
|
||||||
|
*/
|
||||||
|
export async function getBalancesBySteamId(
|
||||||
|
payload: Payload,
|
||||||
|
steamIds: string[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const wanted = [...new Set(steamIds.map((id) => id.trim()).filter((id) => id.length > 0 && id.length <= 64))];
|
||||||
|
if (wanted.length === 0) return {};
|
||||||
|
|
||||||
|
const users = await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
where: { steamId: { in: wanted } },
|
||||||
|
limit: wanted.length * 2,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const userBySteamId = new Map<string, number>();
|
||||||
|
for (const user of users.docs) {
|
||||||
|
const steamId = user.steamId?.trim();
|
||||||
|
if (steamId && !userBySteamId.has(steamId)) userBySteamId.set(steamId, user.id);
|
||||||
|
}
|
||||||
|
if (userBySteamId.size === 0) return {};
|
||||||
|
|
||||||
|
const accounts = (await payload.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ accountType: { equals: "personal" } },
|
||||||
|
{ ownerUser: { in: [...userBySteamId.values()] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: userBySteamId.size * 4,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: BankAccount[] };
|
||||||
|
const balanceByUser = new Map<number, number>();
|
||||||
|
for (const acc of accounts.docs) {
|
||||||
|
const owner = typeof acc.ownerUser === "number" ? acc.ownerUser : acc.ownerUser?.id;
|
||||||
|
// First personal account wins, matching ensurePersonalAccount.
|
||||||
|
if (owner != null && !balanceByUser.has(owner)) balanceByUser.set(owner, acc.balance ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances: Record<string, number> = {};
|
||||||
|
for (const [steamId, userId] of userBySteamId) {
|
||||||
|
balances[steamId] = Math.round((balanceByUser.get(userId) ?? 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
139
tests/int/arma-shop-money.int.spec.ts
Normal file
139
tests/int/arma-shop-money.int.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { BankAccount } from "@/payload-types";
|
||||||
|
import { createAccount, ensurePersonalAccount, getTreasuryAccountId } from "@/lib/banking";
|
||||||
|
import { getBalancesBySteamId, parseMoneyDelta, processMoneyEvent } from "@/lib/arma-bridge/money";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
const RUN = `money-${Date.now().toString(36)}`;
|
||||||
|
const STEAM_ID = `${RUN}-steam-001`;
|
||||||
|
const UNLINKED = `${RUN}-steam-unlinked`;
|
||||||
|
|
||||||
|
async function balanceOf(id: number): Promise<number> {
|
||||||
|
const acc = (await payload.findByID({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as BankAccount;
|
||||||
|
return acc.balance ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSeq = 0;
|
||||||
|
function moneyEvent(delta: number, reason: string, detail = "") {
|
||||||
|
eventSeq += 1;
|
||||||
|
return { eventId: `${RUN}:${eventSeq}`, payload: [STEAM_ID, delta, reason, detail] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Arma shop money sync", () => {
|
||||||
|
let userId: number;
|
||||||
|
let accountId: number;
|
||||||
|
let treasuryId: number;
|
||||||
|
let createdTreasury = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
const user = await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username: `${RUN}-player`,
|
||||||
|
discordUsername: `${RUN}-player-discord`,
|
||||||
|
displayName: `${RUN} Player`,
|
||||||
|
steamId: STEAM_ID,
|
||||||
|
password: "Test1234",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
const existingTreasury = await getTreasuryAccountId(payload);
|
||||||
|
if (existingTreasury) {
|
||||||
|
treasuryId = existingTreasury;
|
||||||
|
} else {
|
||||||
|
const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" });
|
||||||
|
treasuryId = treasury.id;
|
||||||
|
createdTreasury = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (accountId) {
|
||||||
|
await payload.delete({ collection: "bank-accounts", id: accountId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (createdTreasury && treasuryId) {
|
||||||
|
await payload.delete({ collection: "bank-accounts", id: treasuryId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
const profiles = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: userId } },
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
for (const profile of profiles.docs) {
|
||||||
|
await payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseMoneyDelta", () => {
|
||||||
|
it("parses [steamId, delta, reason, detail] and rounds the delta", () => {
|
||||||
|
expect(parseMoneyDelta(["7656119", -250.4, "Purchase", "arifle_MX_F x1"])).toEqual({
|
||||||
|
steamId: "7656119",
|
||||||
|
delta: -250,
|
||||||
|
reason: "purchase",
|
||||||
|
detail: "arifle_MX_F x1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or zero payloads", () => {
|
||||||
|
expect(parseMoneyDelta(null)).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", 100])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["", 100, "reward"])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", 0, "reward"])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", "100", "reward"])).toBeNull();
|
||||||
|
expect(parseMoneyDelta(["7656119", 100, ""])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates reward money in the personal account and reports it by Steam ID", async () => {
|
||||||
|
await processMoneyEvent(payload, moneyEvent(500, "reward", "Dialogue test"));
|
||||||
|
const account = await ensurePersonalAccount(payload, userId);
|
||||||
|
accountId = account.id;
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
|
||||||
|
const balances = await getBalancesBySteamId(payload, [STEAM_ID, UNLINKED]);
|
||||||
|
expect(balances[STEAM_ID]).toBe(500);
|
||||||
|
expect(balances).not.toHaveProperty(UNLINKED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pays purchases to the treasury and refunds them from it", async () => {
|
||||||
|
const treasuryBefore = await balanceOf(treasuryId);
|
||||||
|
|
||||||
|
await processMoneyEvent(payload, moneyEvent(-200, "purchase", "arifle_MX_F x1"));
|
||||||
|
expect(await balanceOf(accountId)).toBe(300);
|
||||||
|
expect(await balanceOf(treasuryId)).toBe(treasuryBefore + 200);
|
||||||
|
|
||||||
|
await processMoneyEvent(payload, moneyEvent(200, "refund", "arifle_MX_F x1"));
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
expect(await balanceOf(treasuryId)).toBe(treasuryBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overdraw: a debit larger than the balance is skipped without throwing", async () => {
|
||||||
|
await expect(processMoneyEvent(payload, moneyEvent(-100000, "purchase"))).resolves.toBeUndefined();
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unlinked players and malformed events without throwing", async () => {
|
||||||
|
await expect(
|
||||||
|
processMoneyEvent(payload, { eventId: `${RUN}:unlinked`, payload: [UNLINKED, 100, "reward"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(processMoneyEvent(payload, { eventId: `${RUN}:bad`, payload: "nope" })).resolves.toBeUndefined();
|
||||||
|
expect(await balanceOf(accountId)).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue