From 73cae3defd36dbfbee1ce0a81dd5661a9f2e5ca3 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 1 Oct 2026 10:04:32 -0400 Subject: [PATCH] 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. --- src/app/api/arma/v1/balances/route.ts | 30 +++++ src/collections/server/ArmaSyncEvents.ts | 7 + src/lib/arma-bridge/money.ts | 164 +++++++++++++++++++++++ tests/int/arma-shop-money.int.spec.ts | 139 +++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 src/app/api/arma/v1/balances/route.ts create mode 100644 src/lib/arma-bridge/money.ts create mode 100644 tests/int/arma-shop-money.int.spec.ts diff --git a/src/app/api/arma/v1/balances/route.ts b/src/app/api/arma/v1/balances/route.ts new file mode 100644 index 0000000..2158221 --- /dev/null +++ b/src/app/api/arma/v1/balances/route.ts @@ -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 }); +} diff --git a/src/collections/server/ArmaSyncEvents.ts b/src/collections/server/ArmaSyncEvents.ts index e44d810..f48176b 100644 --- a/src/collections/server/ArmaSyncEvents.ts +++ b/src/collections/server/ArmaSyncEvents.ts @@ -1,6 +1,7 @@ import { CollectionConfig } from "payload"; import { processPlayerKill } from "@/lib/arma-bridge/processSyncEvents"; import { processBoxEvent } from "@/lib/arma-bridge/crates"; +import { processMoneyEvent } from "@/lib/arma-bridge/money"; import { isOperationLedgerEnabled } from "@/lib/operations/rollout"; 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}`); } } + 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); + } }, ], }, diff --git a/src/lib/arma-bridge/money.ts b/src/lib/arma-bridge/money.ts new file mode 100644 index 0000000..2d5ede9 --- /dev/null +++ b/src/lib/arma-bridge/money.ts @@ -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 { + 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 { + 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> { + 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(); + 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(); + 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 = {}; + for (const [steamId, userId] of userBySteamId) { + balances[steamId] = Math.round((balanceByUser.get(userId) ?? 0) * 100) / 100; + } + return balances; +} diff --git a/tests/int/arma-shop-money.int.spec.ts b/tests/int/arma-shop-money.int.spec.ts new file mode 100644 index 0000000..28e1926 --- /dev/null +++ b/tests/int/arma-shop-money.int.spec.ts @@ -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 { + 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); + }); +});