feat(respawn-tickets): settle attendance-based respawn tickets per mission
Tickets collection, settlement service, arma-bridge API route, one-shot ticketsSettledAt marker on missions, migration, and tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
8d92e7aaa9
commit
7ad9320c02
7 changed files with 27640 additions and 0 deletions
29
src/app/api/arma/v1/tickets/route.ts
Normal file
29
src/app/api/arma/v1/tickets/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireArmaServer } from "@/lib/arma-bridge/server";
|
||||
import { getTicketBalances, settleDueMissions } from "@/lib/respawn-tickets";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Called by the Arma 3 bridge (get_tickets) at mission start and on a slow
|
||||
* poll. Settles any mission whose attendance window has closed before
|
||||
* returning the accumulated balances so the game always reads fresh tickets.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = await requireArmaServer(req);
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
const { payload } = auth;
|
||||
|
||||
let settledCount: number | null = null;
|
||||
try {
|
||||
const result = await settleDueMissions(payload);
|
||||
settledCount = result.settled.length;
|
||||
} catch (error) {
|
||||
// Settlement is best-effort: never block ticket delivery on it.
|
||||
payload.logger.error({ err: error, msg: "respawn ticket settlement failed" });
|
||||
}
|
||||
|
||||
const tickets = await getTicketBalances(payload);
|
||||
return NextResponse.json({ ok: true, settled: settledCount, tickets });
|
||||
}
|
||||
|
|
@ -255,6 +255,15 @@ export const Missions: CollectionConfig = {
|
|||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticketsSettledAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
description:
|
||||
"When respawn tickets for this mission were settled into player balances (managed by the arma-bridge tickets route; one-shot per mission).",
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "createdBy",
|
||||
type: "relationship",
|
||||
|
|
|
|||
35
src/collections/server/RespawnTickets.ts
Normal file
35
src/collections/server/RespawnTickets.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
|
||||
/**
|
||||
* Per-player respawn ticket balances. Tickets are awarded automatically when a
|
||||
* mission is "settled" (see src/lib/respawn-tickets/index.ts): each attendee
|
||||
* earns 1 ticket for a "yes" RSVP, 0.5 for "tentative", 0.25 for "no", and
|
||||
* nothing when they attend without an RSVP. Balances accumulate across
|
||||
* missions and are served to the Arma 3 mod via the arma-bridge tickets
|
||||
* route, which loads them into the game at mission start.
|
||||
*
|
||||
* Written internally by the settlement lib (overrideAccess); the admin panel
|
||||
* is read-only so balance changes always have a settlement behind them.
|
||||
*/
|
||||
export const RespawnTickets: CollectionConfig = {
|
||||
slug: "respawn-tickets",
|
||||
admin: { group: "Server", useAsTitle: "user", defaultColumns: ["user", "balance"] },
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: () => false,
|
||||
update: () => false,
|
||||
delete: () => false,
|
||||
},
|
||||
fields: [
|
||||
{ name: "user", type: "relationship", relationTo: "users", required: true, unique: true, index: true },
|
||||
{
|
||||
name: "balance",
|
||||
type: "number",
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
description:
|
||||
"Accumulated respawn tickets (1 per yes-attended op, 0.5 tentative, 0.25 no). Maintained by settlement; read-only.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
258
src/lib/respawn-tickets/index.ts
Normal file
258
src/lib/respawn-tickets/index.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { getMissionAttendance } from "@/lib/attendance";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import type { Mission, User } from "@/payload-types";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
// Award per actual attendee, keyed by RSVP response (TODO.md: respawn
|
||||
// tickets based on attendance). Attending without an RSVP awards nothing.
|
||||
const AWARD_BY_RESPONSE = { yes: 1, tentative: 0.5, no: 0.25 } as const;
|
||||
type AwardResponse = keyof typeof AWARD_BY_RESPONSE;
|
||||
|
||||
// Attendance is measured inside a window around the mission start: connections
|
||||
// after the next mission begins belong to that operation, and 24h is the
|
||||
// absolute cutoff for a mission that is never followed by another one.
|
||||
const WINDOW_BEFORE_MS = 60 * 60 * 1000;
|
||||
const WINDOW_AFTER_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Draft statuses never settle: Concept/Planning/Ready ops may still be
|
||||
// scrapped, and Cancelled is terminal without attendance.
|
||||
const SETTLEABLE_STATUSES = ["Scheduled", "Active", "Completed"] as const;
|
||||
|
||||
export interface SettleOptions {
|
||||
/** Restrict settlement to these mission ids (tests scope runs this way). */
|
||||
missionIds?: number[];
|
||||
/** Time source for window gating (defaults to now). */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface SettleResult {
|
||||
/** Mission ids settled in this run (ticketsSettledAt stamped). */
|
||||
settled: number[];
|
||||
/** Points added this run, per user id. */
|
||||
awardedByUser: Record<number, number>;
|
||||
}
|
||||
|
||||
interface SyncEventDoc {
|
||||
id: number | string;
|
||||
type: string;
|
||||
occurredAt: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
function steamIdFromSyncEvent(event: SyncEventDoc): string | null {
|
||||
const payload = event.payload;
|
||||
if (!Array.isArray(payload) || payload.length < 2) return null;
|
||||
const raw = payload[1];
|
||||
if (typeof raw === "string" && raw.length > 0) return raw.trim();
|
||||
if (typeof raw === "number") return String(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchAttendeeSteamIds(
|
||||
payload: PayloadType,
|
||||
serverIds: number[],
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
): Promise<Set<string>> {
|
||||
const attendees = new Set<string>();
|
||||
for (const serverId of serverIds) {
|
||||
const res = await payload.find({
|
||||
collection: "arma-sync-events",
|
||||
where: {
|
||||
and: [
|
||||
{ server: { equals: serverId } },
|
||||
{ type: { equals: "player.connected" } },
|
||||
{ occurredAt: { greater_than_equal: windowStart.toISOString() } },
|
||||
{ occurredAt: { less_than_equal: windowEnd.toISOString() } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 5000,
|
||||
sort: "occurredAt",
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const doc of res.docs as unknown as SyncEventDoc[]) {
|
||||
const steamId = steamIdFromSyncEvent(doc);
|
||||
if (steamId) attendees.add(steamId);
|
||||
}
|
||||
}
|
||||
return attendees;
|
||||
}
|
||||
|
||||
async function awardTickets(
|
||||
payload: PayloadType,
|
||||
userId: number,
|
||||
points: number,
|
||||
awardedByUser: Record<number, number>,
|
||||
): Promise<void> {
|
||||
const res = await payload.find({
|
||||
collection: "respawn-tickets",
|
||||
where: { user: { equals: userId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const doc = res.docs[0];
|
||||
const previous = awardedByUser[userId] ?? 0;
|
||||
awardedByUser[userId] = previous + points;
|
||||
|
||||
if (doc) {
|
||||
const balance = Math.round(((doc.balance ?? 0) + points) * 100) / 100;
|
||||
await payload.update({
|
||||
collection: "respawn-tickets",
|
||||
id: doc.id,
|
||||
data: { balance },
|
||||
overrideAccess: true,
|
||||
});
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: "respawn-tickets",
|
||||
data: { user: userId, balance: Math.round(points * 100) / 100 },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function settleMission(
|
||||
payload: PayloadType,
|
||||
mission: Mission,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
result: SettleResult,
|
||||
): Promise<void> {
|
||||
const servers = await payload.find({
|
||||
collection: "game-servers",
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
const serverIds = servers.docs.map((server) => server.id);
|
||||
const attendees = await fetchAttendeeSteamIds(payload, serverIds, windowStart, windowEnd);
|
||||
|
||||
const awardedByUser: Record<number, number> = {};
|
||||
let awardedCount = 0;
|
||||
|
||||
if (attendees.size > 0) {
|
||||
const attendance = await getMissionAttendance(payload, mission.id);
|
||||
for (const response of Object.keys(AWARD_BY_RESPONSE) as AwardResponse[]) {
|
||||
for (const user of attendance[response] as User[]) {
|
||||
if (!user.steamId || !attendees.has(user.steamId.trim())) continue;
|
||||
await awardTickets(payload, user.id, AWARD_BY_RESPONSE[response], awardedByUser);
|
||||
awardedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { ticketsSettledAt: new Date().toISOString() },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
result.settled.push(mission.id);
|
||||
for (const [userId, points] of Object.entries(awardedByUser)) {
|
||||
const existing = result.awardedByUser[Number(userId)] ?? 0;
|
||||
result.awardedByUser[Number(userId)] = Math.round((existing + points) * 100) / 100;
|
||||
}
|
||||
|
||||
if (awardedCount > 0) {
|
||||
emitGameEvent(payload, {
|
||||
type: EventTypes.RespawnTicketsSettled,
|
||||
message: `${mission.name ?? `Mission #${mission.id}`}: respawn tickets settled (${awardedCount} attendee${awardedCount === 1 ? "" : "s"} awarded).`,
|
||||
targetCollection: "missions",
|
||||
targetId: mission.id,
|
||||
data: { awardedByUser },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles respawn tickets for every mission whose attendance window has
|
||||
* closed: RSVP responses are matched against actual attendance (player
|
||||
* connections on the arma-sync-events feed) and the awards are accumulated
|
||||
* into per-player respawn-tickets balances. Idempotent via the mission's
|
||||
* ticketsSettledAt marker.
|
||||
*/
|
||||
export async function settleDueMissions(
|
||||
payload: PayloadType,
|
||||
options: SettleOptions = {},
|
||||
): Promise<SettleResult> {
|
||||
const now = options.now ?? new Date();
|
||||
const result: SettleResult = { settled: [], awardedByUser: {} };
|
||||
|
||||
const missions = await payload.find({
|
||||
collection: "missions",
|
||||
where: {
|
||||
and: [
|
||||
{ "ownershipAndStatus.status": { in: [...SETTLEABLE_STATUSES] } },
|
||||
...(options.missionIds?.length ? [{ id: { in: options.missionIds } }] : []),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const all = await payload.find({
|
||||
collection: "missions",
|
||||
...(options.missionIds?.length ? { where: { id: { in: options.missionIds } } } : {}),
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const startMs = (mission: Mission): number | null => {
|
||||
const start = mission.classification?.startDateTime;
|
||||
if (!start) return null;
|
||||
return new Date(start).getTime();
|
||||
};
|
||||
|
||||
const candidates = (missions.docs as unknown as Mission[])
|
||||
.filter((mission) => !mission.ticketsSettledAt && startMs(mission) !== null)
|
||||
.sort((a, b) => (startMs(a) as number) - (startMs(b) as number));
|
||||
|
||||
for (const mission of candidates) {
|
||||
const missionStart = startMs(mission) as number;
|
||||
const laterStarts = (all.docs as unknown as Mission[])
|
||||
.map((other) => (other.id === mission.id ? null : startMs(other)))
|
||||
.filter((value): value is number => value !== null && value > missionStart);
|
||||
const nextMissionStart = laterStarts.length ? Math.min(...laterStarts) : null;
|
||||
|
||||
const windowStart = new Date(missionStart - WINDOW_BEFORE_MS);
|
||||
const windowEnd = new Date(
|
||||
Math.min(missionStart + WINDOW_AFTER_MS, nextMissionStart ?? Infinity),
|
||||
);
|
||||
if (now.getTime() < windowEnd.getTime()) continue;
|
||||
|
||||
await settleMission(payload, mission, windowStart, windowEnd, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved ticket balances keyed by the player's steam id (only balances
|
||||
* above zero), in the shape the Arma 3 mod expects.
|
||||
*/
|
||||
export async function getTicketBalances(
|
||||
payload: PayloadType,
|
||||
): Promise<Record<string, number>> {
|
||||
const res = await payload.find({
|
||||
collection: "respawn-tickets",
|
||||
where: { balance: { greater_than: 0 } },
|
||||
depth: 1,
|
||||
limit: 10000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const tickets: Record<string, number> = {};
|
||||
for (const doc of res.docs) {
|
||||
const user = doc.user as User;
|
||||
if (!user || typeof user === "number" || !user.steamId) continue;
|
||||
tickets[user.steamId] = Math.round((doc.balance ?? 0) * 100) / 100;
|
||||
}
|
||||
return tickets;
|
||||
}
|
||||
26979
src/migrations/20260914_171412_add_respawn_tickets.json
Normal file
26979
src/migrations/20260914_171412_add_respawn_tickets.json
Normal file
File diff suppressed because it is too large
Load diff
40
src/migrations/20260914_171412_add_respawn_tickets.ts
Normal file
40
src/migrations/20260914_171412_add_respawn_tickets.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "respawn_tickets" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"balance" numeric DEFAULT 0,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "missions" ADD COLUMN "tickets_settled_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "respawn_tickets_find" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "respawn_tickets_create" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "respawn_tickets_update" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_mcp_api_keys" ADD COLUMN "respawn_tickets_delete" boolean DEFAULT false;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "respawn_tickets_id" integer;
|
||||
ALTER TABLE "respawn_tickets" ADD CONSTRAINT "respawn_tickets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
CREATE UNIQUE INDEX "respawn_tickets_user_idx" ON "respawn_tickets" USING btree ("user_id");
|
||||
CREATE INDEX "respawn_tickets_updated_at_idx" ON "respawn_tickets" USING btree ("updated_at");
|
||||
CREATE INDEX "respawn_tickets_created_at_idx" ON "respawn_tickets" USING btree ("created_at");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_respawn_tickets_fk" FOREIGN KEY ("respawn_tickets_id") REFERENCES "public"."respawn_tickets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_respawn_tickets_id_idx" ON "payload_locked_documents_rels" USING btree ("respawn_tickets_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "respawn_tickets" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "respawn_tickets" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_respawn_tickets_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_respawn_tickets_id_idx";
|
||||
ALTER TABLE "missions" DROP COLUMN "tickets_settled_at";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "respawn_tickets_find";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "respawn_tickets_create";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "respawn_tickets_update";
|
||||
ALTER TABLE "payload_mcp_api_keys" DROP COLUMN "respawn_tickets_delete";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "respawn_tickets_id";`)
|
||||
}
|
||||
290
tests/int/respawn-tickets.int.spec.ts
Normal file
290
tests/int/respawn-tickets.int.spec.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
|
||||
import { getTicketBalances, settleDueMissions } from "@/lib/respawn-tickets";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `rst-${Date.now().toString(36)}`;
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
// Fixed past dates so settlement windows are deterministic and already due.
|
||||
const T0 = new Date("2026-09-01T12:00:00.000Z");
|
||||
const NOW = new Date(T0.getTime() + 51 * HOUR);
|
||||
|
||||
describe("Respawn ticket settlement", () => {
|
||||
let mapId: number;
|
||||
let campaignId: number;
|
||||
let serverId: number;
|
||||
let uYes: User;
|
||||
let uTent: User;
|
||||
let uNo: User;
|
||||
let uNone: User;
|
||||
let uGhost: User;
|
||||
let m1: Mission;
|
||||
let m2: Mission;
|
||||
let m3: Mission;
|
||||
const missionIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const attendanceIds: number[] = [];
|
||||
const eventIds: number[] = [];
|
||||
const ticketIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const map = (await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map` },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as MissionMap;
|
||||
mapId = map.id;
|
||||
|
||||
const campaign = (await payload.create({
|
||||
collection: "campaigns",
|
||||
data: { name: `${RUN} Campaign`, summary: "Respawn tickets test campaign", status: "concept", campaignMode: "custom" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Campaign;
|
||||
campaignId = campaign.id;
|
||||
|
||||
const server = (await payload.create({
|
||||
collection: "game-servers",
|
||||
data: { serverId: `${RUN}-host`, name: `${RUN} Server`, status: "online" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
serverId = server.id;
|
||||
|
||||
const makeUser = async (label: string): Promise<User> => {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}`,
|
||||
displayName: label.toUpperCase(),
|
||||
steamId: `${RUN}-sid-${label}`,
|
||||
password: "Test123",
|
||||
roles: ["user"],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
uYes = await makeUser("yes");
|
||||
uTent = await makeUser("tent");
|
||||
uNo = await makeUser("no");
|
||||
uNone = await makeUser("none");
|
||||
uGhost = await makeUser("ghost");
|
||||
|
||||
const makeMission = async (label: string, status: Mission["ownershipAndStatus"]["status"], start: Date): Promise<Mission> => {
|
||||
const mission = (await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
name: `${RUN} ${label}`,
|
||||
codeName: `${RUN}-${label}`,
|
||||
summary: "Respawn ticket settlement test mission",
|
||||
operationType: "main",
|
||||
classification: {
|
||||
map: mapId,
|
||||
missionType: "PvE",
|
||||
campaign: campaignId,
|
||||
startDateTime: start.toISOString(),
|
||||
estimatedDuration: 60,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
authors: [uYes.id],
|
||||
status,
|
||||
visibility: "unit",
|
||||
},
|
||||
missionRoles: { maxPlayers: 16 },
|
||||
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
|
||||
briefing: [],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Mission;
|
||||
missionIds.push(mission.id);
|
||||
return mission;
|
||||
};
|
||||
|
||||
// M1 window: [T0 - 1h, min(T0 + 24h, M2 start = T0 + 26h)] -> closes T0 + 24h.
|
||||
m1 = await makeMission("op-one", "Scheduled", T0);
|
||||
// M2 window: [T0 + 25h, min(T0 + 50h, M3 start (far future))] -> closes T0 + 50h.
|
||||
m2 = await makeMission("op-two", "Completed", new Date(T0.getTime() + 26 * HOUR));
|
||||
// Far future: never inside any settled window at NOW.
|
||||
m3 = await makeMission("op-three", "Scheduled", new Date(T0.getTime() + 30 * 24 * HOUR));
|
||||
|
||||
const makeAttendance = async (missionId: number, userId: number, response: "yes" | "tentative" | "no"): Promise<void> => {
|
||||
const doc = (await payload.create({
|
||||
collection: "mission-attendances",
|
||||
data: { mission: missionId, user: userId, response },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
attendanceIds.push(doc.id);
|
||||
};
|
||||
|
||||
await makeAttendance(m1.id, uYes.id, "yes");
|
||||
await makeAttendance(m1.id, uTent.id, "tentative");
|
||||
await makeAttendance(m1.id, uNo.id, "no");
|
||||
await makeAttendance(m1.id, uGhost.id, "yes");
|
||||
await makeAttendance(m2.id, uYes.id, "yes");
|
||||
|
||||
const makeConnection = async (occurredAt: Date, steamId: string): Promise<void> => {
|
||||
const event = (await payload.create({
|
||||
collection: "arma-sync-events",
|
||||
data: {
|
||||
eventId: `${RUN}-${steamId}-${occurredAt.getTime()}`,
|
||||
server: serverId,
|
||||
type: "player.connected",
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
payload: ["1", steamId, "Player", false, "2", "3"],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
eventIds.push(event.id);
|
||||
};
|
||||
|
||||
// M1 attendance: yes, tent, no, none connect; ghost never does.
|
||||
await makeConnection(new Date(T0.getTime() - 10 * 60_000), `${RUN}-sid-yes`);
|
||||
await makeConnection(new Date(T0.getTime() + 5 * 60_000), `${RUN}-sid-tent`);
|
||||
await makeConnection(new Date(T0.getTime() + 10 * 60_000), `${RUN}-sid-no`);
|
||||
await makeConnection(new Date(T0.getTime() + 15 * 60_000), `${RUN}-sid-none`);
|
||||
// M2 attendance: only yes returns for the next operation.
|
||||
await makeConnection(new Date(T0.getTime() + 27 * HOUR), `${RUN}-sid-yes`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const cleanup = async (fn: () => Promise<unknown>): Promise<void> => {
|
||||
await fn().catch(() => {});
|
||||
};
|
||||
for (const id of attendanceIds) {
|
||||
await cleanup(() => payload.delete({ collection: "mission-attendances", id, overrideAccess: true }));
|
||||
}
|
||||
for (const id of eventIds) {
|
||||
await cleanup(() => payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }));
|
||||
}
|
||||
for (const id of ticketIds) {
|
||||
await cleanup(() => payload.delete({ collection: "respawn-tickets", id, overrideAccess: true }));
|
||||
}
|
||||
const settledEvents = await payload
|
||||
.find({
|
||||
collection: "game-event-logs",
|
||||
where: {
|
||||
and: [
|
||||
{ type: { in: ["respawn:tickets-settled", "mission:attendance-change"] } },
|
||||
{ targetId: { in: missionIds } },
|
||||
],
|
||||
},
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null);
|
||||
for (const event of settledEvents?.docs ?? []) {
|
||||
await cleanup(() => payload.delete({ collection: "game-event-logs", id: event.id, overrideAccess: true }));
|
||||
}
|
||||
for (const id of missionIds) {
|
||||
await cleanup(() => payload.delete({ collection: "missions", id, overrideAccess: true }));
|
||||
}
|
||||
await cleanup(() => payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }));
|
||||
await cleanup(() => payload.delete({ collection: "campaigns", id: campaignId, overrideAccess: true }));
|
||||
await cleanup(() => payload.delete({ collection: "maps", id: mapId, overrideAccess: true }));
|
||||
for (const id of userIds) {
|
||||
const accounts = await payload
|
||||
.find({ collection: "bank-accounts", where: { ownerUser: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const account of accounts?.docs ?? []) {
|
||||
await cleanup(() => payload.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }));
|
||||
}
|
||||
const profiles = await payload
|
||||
.find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
for (const profile of profiles?.docs ?? []) {
|
||||
await cleanup(() => payload.delete({ collection: "profiles", id: profile.id, overrideAccess: true }));
|
||||
}
|
||||
await cleanup(() => payload.delete({ collection: "users", id, overrideAccess: true }));
|
||||
}
|
||||
});
|
||||
|
||||
const balanceOf = async (userId: number): Promise<number | null> => {
|
||||
const res = await payload.find({
|
||||
collection: "respawn-tickets",
|
||||
where: { user: { equals: userId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (!res.docs[0]) return null;
|
||||
ticketIds.push(res.docs[0].id);
|
||||
return res.docs[0].balance ?? null;
|
||||
};
|
||||
|
||||
it("awards tickets by RSVP x actual attendance and accumulates across missions", async () => {
|
||||
const result = await settleDueMissions(payload, { now: NOW, missionIds: missionIds });
|
||||
|
||||
expect(result.settled.sort()).toEqual([m1.id, m2.id].sort());
|
||||
|
||||
// yes + attended on both ops: 1 + 1 = 2.
|
||||
expect(await balanceOf(uYes.id)).toBe(2);
|
||||
// tentative + attended: 0.5.
|
||||
expect(await balanceOf(uTent.id)).toBe(0.5);
|
||||
// no + attended: 0.25.
|
||||
expect(await balanceOf(uNo.id)).toBe(0.25);
|
||||
// Attended without an RSVP: no doc at all.
|
||||
expect(await balanceOf(uNone.id)).toBeNull();
|
||||
// RSVPed yes but never connected: no doc at all.
|
||||
expect(await balanceOf(uGhost.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("serves steamId-keyed balances above zero to the bridge", async () => {
|
||||
const tickets = await getTicketBalances(payload);
|
||||
expect(tickets).toEqual({
|
||||
[`${RUN}-sid-yes`]: 2,
|
||||
[`${RUN}-sid-tent`]: 0.5,
|
||||
[`${RUN}-sid-no`]: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it("never settles missions whose window has not closed", async () => {
|
||||
const fresh = await payload.findByID({ collection: "missions", id: m3.id, depth: 0, overrideAccess: true }) as unknown as Mission;
|
||||
expect(fresh.ticketsSettledAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("is idempotent — a second settlement awards nothing new", async () => {
|
||||
const before = await getTicketBalances(payload);
|
||||
const result = await settleDueMissions(payload, { now: NOW, missionIds: missionIds });
|
||||
expect(result.settled).toEqual([]);
|
||||
expect(await getTicketBalances(payload)).toEqual(before);
|
||||
});
|
||||
|
||||
it("emits a respawn:tickets-settled event per settled mission", async () => {
|
||||
const poll = async (): Promise<number> => {
|
||||
const res = await payload.find({
|
||||
collection: "game-event-logs",
|
||||
where: {
|
||||
and: [{ type: { equals: "respawn:tickets-settled" } }, { targetId: { in: [m1.id, m2.id] } }],
|
||||
},
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return res.docs.length;
|
||||
};
|
||||
let count = await poll();
|
||||
for (let attempt = 0; attempt < 20 && count < 2; attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
count = await poll();
|
||||
}
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue