openvibers/source.openvibe.games Commits
Round system, batch rewards, and C++ RoundStart/RoundEnd bridge
Changes
@@@ -21,7 +21,7 @@| Component | Status | Notes || --- | --- | --- |--| Backend API | Complete for MVP | Dev auth, Steam ticket auth route, inventory, shop, travel, rewards, leaderboard, asset manifest. |++| Backend API | Complete for MVP | Dev auth, Steam ticket auth route, inventory, shop, travel, rewards, leaderboard, asset manifest, batch match rewards. || PostgreSQL schema | Complete for MVP | Durable players, currency, inventory, servers, join tokens, match results. || Redis sessions | Implemented | Enabled when `SESSION_REDIS_URL` is set. || C++ client travel | Implemented | `ov_join <mode>` calls `/v1/travel/request` and connects with token. |@@@ -42,6 +42,9 @@ openvibe-source/| Fort Wars placement | Implemented | `ov_fortwars_spawn` || VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` || Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes |++| JS round system | Implemented | Base round state machine; Prop Hunt, Deathrun, Fort Wars, Traitor Town each with teams, timers, win conditions |++| Batch match rewards | Implemented | `POST /v1/matches/end/batch` — rewards multiple players in one idempotent call |++| C++ RoundStart/RoundEnd | Implemented | `OpenVibeJS_Server_RoundStart/End`, `ov_round_start`, `ov_round_end` ConCommands || Maps | Compiled | `game/openvibe.games/maps/*.bsp` || Production infra | Scaffolded | `infra/docker-compose.yml`, `infra/production/docker-stack.yml`, `infra/kubernetes/openvibe.yaml` |@@@ -89,10 +92,10 @@ node tools/smoke-api.mjstools/dev-down.sh```--## Verified On June 29, 2026++## Verified On June 30, 2026- Backend TypeScript build passed.--- Backend Vitest suite passed: 12 tests.++- Backend Vitest suite passed: 13 tests.- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.- SRCDS smoke test passed for all five maps.- Full dev stack registered all five servers and returned valid travel targets for every mode.@@@ -538,4 +538,61 @@ describe("OpenVibe API vertical slice", () => {await app.close();});++++ it("batch match rewards: rewards multiple players idempotently and rejects wrong secret", async () => {++ const app = await testApp();++ const player2 = "76561198000000010";++ const player3 = "76561198000000011";++++ // Register players++ await app.inject({ method: "POST", url: "/v1/auth/dev",++ payload: { steamId, displayName: "Batch Player 1" } });++ await app.inject({ method: "POST", url: "/v1/auth/dev",++ payload: { steamId: player2, displayName: "Batch Player 2" } });++ await app.inject({ method: "POST", url: "/v1/auth/dev",++ payload: { steamId: player3, displayName: "Batch Player 3" } });++++ // Register server++ await app.inject({ method: "POST", url: "/v1/servers/register",++ payload: { serverId: "local-prophunt-batch", serverSecret, mode: "prophunt",++ mapName: "ph_openvibe_dev", publicHost: "127.0.0.1", port: 27099, maxPlayers: 24 } });++++ const batchPayload = {++ matchId: "prophunt-round-batch-1",++ serverId: "local-prophunt-batch",++ serverSecret,++ mode: "prophunt",++ results: [++ { steamId, rewardCurrency: 50, rewardXp: 100, stats: { winner: true } },++ { steamId: player2, rewardCurrency: 25, rewardXp: 50 },++ { steamId: player3, rewardCurrency: 10, rewardXp: 20 },++ ],++ };++++ // Wrong secret is rejected++ const badSecret = await app.inject({++ method: "POST",++ url: "/v1/matches/end/batch",++ payload: { ...batchPayload, serverSecret: "wrong-secret" },++ });++ expect(badSecret.statusCode).toBe(403);++++ // First batch succeeds++ const first = await app.inject({ method: "POST", url: "/v1/matches/end/batch", payload: batchPayload });++ expect(first.statusCode).toBe(200);++ expect(first.json().rewarded).toBe(3);++ expect(first.json().profiles).toHaveLength(3);++++ // Find player 1's profile in results++ const p1 = first.json().profiles.find((p: { player: { steamId: string } }) => p.player.steamId === steamId);++ expect(p1.player.currencyBalance).toBe(300); // 250 seed + 50++ expect(p1.player.xp).toBe(100);++++ // Duplicate batch is idempotent (no double rewards)++ const second = await app.inject({ method: "POST", url: "/v1/matches/end/batch", payload: batchPayload });++ expect(second.statusCode).toBe(200);++ expect(second.json().rewarded).toBe(0);++++ await app.close();++ });});@@@ -8,6 +8,7 @@ import {acceptPartyInviteSchema,auditEventSchema,auditQuerySchema,++ batchMatchEndSchema,buyItemSchema,createPartySchema,equipItemSchema,@@@ -277,6 +278,13 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {return profile;});++ app.post("/v1/matches/end/batch", async (request, reply) => {++ const body = batchMatchEndSchema.parse(request.body);++ const profiles = await options.repository.recordBatchMatchRewards(body);++ if (profiles === null) return reply.code(403).send({ error: "invalid_server_secret" });++ return { rewarded: profiles.length, profiles };++ });++// GET /v1/leaderboard?limit=10&mode=prophuntapp.get("/v1/leaderboard", async (request) => {const query = leaderboardQuerySchema.parse(request.query);@@@ -194,6 +194,19 @@ export interface MatchRewardInput {stats?: Record<string, unknown>;}++export interface BatchMatchRewardInput {++ matchId: string;++ serverId: string;++ serverSecret: string;++ mode: GameMode;++ results: Array<{++ steamId: string;++ rewardCurrency: number;++ rewardXp: number;++ stats?: Record<string, unknown>;++ }>;++}++export interface UpsertShopItemInput {itemId: string;itemType: ItemType;@@@ -233,6 +246,7 @@ export interface OpenVibeRepository {serverId: string;}): Promise<JoinTokenValidation>;recordMatchReward(input: MatchRewardInput): Promise<PlayerProfile | null>;++ recordBatchMatchRewards(input: BatchMatchRewardInput): Promise<PlayerProfile[] | null>;getLeaderboard(options: { limit: number; mode?: GameMode }): Promise<LeaderboardEntry[]>;upsertShopItem(input: UpsertShopItemInput): Promise<ShopItem>;recordAuditEvent(input: {@@@ -1,5 +1,6 @@import { nanoid } from "nanoid";import {++ BatchMatchRewardInput,GameMode,GameServer,HeartbeatInput,@@@ -354,6 +355,27 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {return this.profileFor(input.steamId);}++ async recordBatchMatchRewards(input: BatchMatchRewardInput): Promise<PlayerProfile[] | null> {++ const server = this.servers.get(input.serverId);++ if (!server || server.serverSecret !== input.serverSecret) return null;++++ const profiles: PlayerProfile[] = [];++ for (const entry of input.results) {++ const player = this.players.get(entry.steamId);++ if (!player) continue;++++ const key = `${input.matchId}:${entry.steamId}`;++ if (!this.rewardKeys.has(key)) {++ player.currencyBalance += entry.rewardCurrency;++ player.xp += entry.rewardXp;++ this.rewardKeys.add(key);++ profiles.push(this.profileFor(entry.steamId));++ }++ }++++ return profiles;++ }++private profileFor(steamId: string): PlayerProfile {const player = this.requirePlayer(steamId);const owned = this.ensureInventory(steamId);@@@ -1,5 +1,6 @@import { Pool } from "pg";import {++ BatchMatchRewardInput,GameMode,GameServer,HeartbeatInput,@@@ -576,6 +577,84 @@ export class PgOpenVibeRepository implements OpenVibeRepository {return this.getProfile(input.steamId);}++ async recordBatchMatchRewards(input: BatchMatchRewardInput): Promise<PlayerProfile[] | null> {++ const client = await this.pool.connect();++ const rewarded: string[] = [];++++ try {++ await client.query("BEGIN");++++ const serverResult = await client.query(++ "SELECT 1 FROM game_servers WHERE server_id = $1 AND server_secret = $2",++ [input.serverId, input.serverSecret],++ );++ if (serverResult.rowCount !== 1) {++ await client.query("COMMIT");++ return null;++ }++++ for (const entry of input.results) {++ const inserted = await client.query(++ `++ INSERT INTO match_results (++ match_id, server_id, steam_id, mode, reward_currency, reward_xp, stats++ )++ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)++ ON CONFLICT (match_id, steam_id) DO NOTHING++ RETURNING result_id++ `,++ [++ input.matchId,++ input.serverId,++ entry.steamId,++ input.mode,++ entry.rewardCurrency,++ entry.rewardXp,++ JSON.stringify(entry.stats ?? {}),++ ],++ );++++ if (inserted.rowCount === 1) {++ await client.query(++ `++ UPDATE players++ SET currency_balance = currency_balance + $2,++ xp = xp + $3,++ updated_at = now()++ WHERE steam_id = $1++ `,++ [entry.steamId, entry.rewardCurrency, entry.rewardXp],++ );++++ await client.query(++ `++ INSERT INTO currency_ledger (steam_id, delta, reason, idempotency_key)++ VALUES ($1, $2, 'match_reward', $3)++ ON CONFLICT DO NOTHING++ `,++ [entry.steamId, entry.rewardCurrency, `match:${input.matchId}:${entry.steamId}`],++ );++++ rewarded.push(entry.steamId);++ }++ }++++ await client.query("COMMIT");++ } catch (error) {++ await client.query("ROLLBACK");++ throw error;++ } finally {++ client.release();++ }++++ const profiles: PlayerProfile[] = [];++ for (const steamId of rewarded) {++ const profile = await this.getProfile(steamId);++ if (profile) profiles.push(profile);++ }++ return profiles;++ }++async getLeaderboard(options: { limit: number; mode?: GameMode }): Promise<LeaderboardEntry[]> {const result = await this.pool.query(`@@@ -98,6 +98,24 @@ export const matchEndSchema = z.object({stats: z.record(z.string(), z.unknown()).optional(),});++export const batchMatchEndSchema = z.object({++ matchId: z.string().min(3).max(120),++ serverId,++ serverSecret,++ mode,++ results: z++ .array(++ z.object({++ steamId,++ rewardCurrency: z.number().int().min(0).max(5000),++ rewardXp: z.number().int().min(0).max(50000),++ stats: z.record(z.string(), z.unknown()).optional(),++ }),++ )++ .min(1)++ .max(64),++});++export const leaderboardQuerySchema = z.object({limit: z.coerce.number().int().min(1).max(100).default(10),mode: mode.optional(),@@@ -204,6 +204,11 @@ EntityTakeDamage(entity, damageInfo)PlayerUse(ply, entity)PlayerCanPickupWeapon(ply, weapon)PlayerLoadout(ply)--RoundStart--RoundEnd++```++++Already added via `ov_round_start` / `ov_round_end` ConCommands and direct C++ bridge calls:++++```text++RoundStart(roundNumber)++RoundEnd(roundNumber, reason)```@@@ -1,14 +1,30 @@(function () {++ // Round state: "idle" → "countdown" → "active" → "ended"++ // Override startRound() and endRound() in submode gamemodes for custom logic.++ // RoundStart and RoundEnd hooks fire at the appropriate transitions.++++ const DEFAULT_ROUND_DURATION = 300; // 5 minutes++ const DEFAULT_COUNTDOWN = 10; // 10-second warmup++const GM = {mode: "base",name: "OpenVibe Base",++ // Round settings — override in submode++ roundDuration: DEFAULT_ROUND_DURATION,++ countdownDuration: DEFAULT_COUNTDOWN,++++ // Round tracking (do not override)++ _roundState: "idle", // "idle" | "countdown" | "active" | "ended"++ _roundNumber: 0,++Initialize() {OV.log("Base Initialize fired");},MapInitialize(mapName) {OV.log(`Base MapInitialize: ${mapName}`);++ this.scheduleRoundStart();},PlayerInitialSpawn(ply) {@@@ -27,6 +43,57 @@return undefined;},++ // Called when the countdown timer ends — fires "RoundStart" and runs round timer.++ startRound() {++ this._roundNumber += 1;++ this._roundState = "active";++ OV.log(`[OV Round] RoundStart round=${this._roundNumber}`);++ hook.Run("RoundStart", this._roundNumber);++ OV.broadcast(`Round ${this._roundNumber} — begin!`);++++ const roundDuration = this.roundDuration;++ const roundNumber = this._roundNumber;++ const self = this;++ timer.create(`ov_round_end_${roundNumber}`, roundDuration, 1, function () {++ if (self._roundState === "active" && self._roundNumber === roundNumber) {++ self.endRound("time");++ }++ });++ },++++ // Called to end the current active round. reason: "time" | "win" | "loss" | "draw"++ endRound(reason) {++ if (this._roundState !== "active") return;++ this._roundState = "ended";++ OV.log(`[OV Round] RoundEnd round=${this._roundNumber} reason=${reason}`);++ hook.Run("RoundEnd", this._roundNumber, reason || "time");++ OV.broadcast(`Round ${this._roundNumber} ended (${reason || "time"}).`);++ timer.remove(`ov_round_end_${this._roundNumber}`);++ this.scheduleRoundStart();++ },++++ // Begin countdown then start round.++ scheduleRoundStart() {++ const players = OV.players ? OV.players() : [];++ if (players.length === 0) {++ // No players — wait and retry++ this._roundState = "idle";++ const self = this;++ timer.simple(5, function () { self.scheduleRoundStart(); });++ return;++ }++++ this._roundState = "countdown";++ const cd = this.countdownDuration;++ OV.broadcast(`Next round in ${cd} seconds.`);++ const self = this;++ timer.simple(cd, function () {++ if (self._roundState === "countdown") {++ self.startRound();++ }++ });++ },++Think() {}};@@@ -1,8 +1,46 @@(function () {++ // Deathrun round state++ // Teams: RUNNERS (team 2) vs ACTIVATORS (team 3)++ // One activator controls traps; all others are runners.++ // Win conditions:++ // - All runners die → activator wins++ // - Any runner reaches the finish (fires "DR_RunnerFinished") → runners win++ // - Timer runs out with ≥1 runner alive → runners win++++ const ROUND_DURATION = 240; // 4 minutes per round++++ const TEAM_RUNNERS = 2;++ const TEAM_ACTIVATORS = 3;++++ let runnersAlive = 0;++ let activatorId = null; // userId of the activator this round++++ function assignTeams() {++ const players = OV.players();++ if (players.length === 0) return;++++ const shuffled = players.slice().sort(() => Math.random() - 0.5);++ // Pick one activator (rotate by round number)++ const activatorIdx = (gamemode.get()._roundNumber - 1) % shuffled.length;++ shuffled.forEach(function (p, i) {++ if (i === activatorIdx) {++ p.setTeam(TEAM_ACTIVATORS);++ activatorId = p.userId();++ } else {++ p.setTeam(TEAM_RUNNERS);++ }++ });++++ runnersAlive = players.filter((p) => p.team() === TEAM_RUNNERS).length;++ const activator = players.find((p) => p.userId() === activatorId);++ OV.broadcast(`Deathrun: ${runnersAlive} runner(s) vs activator ${activator ? activator.name() : "?"}.`);++ }++function registerCommands() {if (!globalThis.command) return;command.add("dr_status", "Show Deathrun status", function ({ ply, reply }) {-- reply(ply, `Deathrun JS online. map=${OV.getMapName()} players=${OV.players().length}`);++ const gm = gamemode.get();++ reply(ply, `Deathrun | round=${gm._roundNumber} state=${gm._roundState} runners=${runnersAlive}`);return false;});}@@@ -10,13 +48,98 @@const GM = {mode: "deathrun",name: "OpenVibe Deathrun",-- Initialize() { OV.log("Deathrun Initialize fired"); registerCommands(); },-- PlayerInitialSpawn(ply) { ply.chat("Deathrun JS loaded. Try !dr_status"); },++ roundDuration: ROUND_DURATION,++ countdownDuration: 10,++++ Initialize() {++ OV.log("Deathrun Initialize fired");++ registerCommands();++ },++++ MapInitialize(mapName) {++ OV.log(`Deathrun MapInitialize: ${mapName}`);++ this.scheduleRoundStart();++ },++++ PlayerInitialSpawn(ply) {++ ply.chat("Deathrun JS loaded. Try !dr_status");++ },++++ startRound() {++ this._roundNumber += 1;++ this._roundState = "active";++ activatorId = null;++++ assignTeams();++++ OV.log(`[DR] RoundStart round=${this._roundNumber}`);++ hook.Run("RoundStart", this._roundNumber);++ OV.broadcast(`Round ${this._roundNumber} — reach the end. Avoid traps. Trust nothing.`);++++ const self = this;++ const roundNum = this._roundNumber;++ timer.create(`ov_dr_round_end_${roundNum}`, ROUND_DURATION, 1, function () {++ if (self._roundState === "active" && self._roundNumber === roundNum) {++ if (runnersAlive > 0) {++ self.endRound("runners_win");++ } else {++ self.endRound("activator_win");++ }++ }++ });++ },++++ endRound(reason) {++ if (this._roundState !== "active") return;++ this._roundState = "ended";++ timer.remove(`ov_dr_round_end_${this._roundNumber}`);++++ let msg;++ if (reason === "runners_win") {++ msg = "Runners survived! Runners win!";++ } else if (reason === "activator_win") {++ msg = "All runners eliminated! Activator wins!";++ } else {++ msg = `Round ${this._roundNumber} ended.`;++ }++++ OV.log(`[DR] RoundEnd round=${this._roundNumber} reason=${reason}`);++ hook.Run("RoundEnd", this._roundNumber, reason);++ OV.broadcast(msg);++++ this.scheduleRoundStart();++ },++++ PlayerDeath(victim, _attacker) {++ if (!victim || this._roundState !== "active") return;++ if (victim.team() === TEAM_RUNNERS) {++ runnersAlive = Math.max(0, runnersAlive - 1);++ if (runnersAlive === 0) {++ this.endRound("activator_win");++ } else {++ OV.broadcast(`Runner down! ${runnersAlive} runner(s) remaining.`);++ }++ }++ },++Think() {}};++ // Any runner reaching the finish fires DR_RunnerFinished++ hook.Add("DR_RunnerFinished", "OpenVibeDeathrunFinish", function (ply) {++ const gm = gamemode.get();++ if (gm && gm._roundState === "active") {++ OV.broadcast(`${ply ? ply.name() : "A runner"} reached the end!`);++ gm.endRound("runners_win");++ }++ });++hook.Add("PlayerSpawn", "OpenVibeDeathrunSpawnTip", function (ply) {-- ply.chat("Reach the end. Avoid traps. Trust nothing.");++ if (ply.team() === TEAM_RUNNERS) {++ ply.chat("Reach the end. Avoid traps. Trust nothing.");++ } else {++ ply.chat("Activate the traps! Stop the runners!");++ }});gamemode.set(GM);@@@ -1,15 +1,50 @@(function () {++ // Fort Wars round state++ // Phase 1 — BUILD (team 2 vs team 3 each build separately)++ // Phase 2 — FIGHT (teams attack each other's fort)++ // Win condition: last team with living players wins.++++ const BUILD_DURATION = 60; // 60 s build phase++ const FIGHT_DURATION = 180; // 3 min fight phase++++ const TEAM_RED = 2;++ const TEAM_BLUE = 3;++++ let phase = "build"; // "build" | "fight"++ let redAlive = 0;++ let blueAlive = 0;++++ function assignTeams() {++ const players = OV.players();++ if (players.length === 0) return;++ const shuffled = players.slice().sort(() => Math.random() - 0.5);++ const half = Math.ceil(shuffled.length / 2);++ shuffled.forEach(function (p, i) {++ p.setTeam(i < half ? TEAM_RED : TEAM_BLUE);++ });++ redAlive = shuffled.filter((p) => p.team() === TEAM_RED).length;++ blueAlive = shuffled.filter((p) => p.team() === TEAM_BLUE).length;++ OV.broadcast(`Fort Wars: Red (${redAlive}) vs Blue (${blueAlive})`);++ }++function registerCommands() {if (!globalThis.command) return;++command.add("fw_status", "Show Fort Wars status", function ({ ply, reply }) {-- reply(ply, `Fort Wars JS online. map=${OV.getMapName()} players=${OV.players().length}`);++ const gm = gamemode.get();++ reply(ply, `Fort Wars | round=${gm._roundNumber} phase=${phase} red=${redAlive} blue=${blueAlive}`);return false;});-- command.add("build", "Spawn an allowlisted Fort Wars prop", function ({ args, ply, reply }) {++++ command.add("build", "Spawn a Fort Wars prop", function ({ args, ply, reply }) {++ if (phase !== "build") {++ reply(ply, "Build phase has ended!");++ return false;++ }const choice = args[0] || "crate";if (!ply) return false;ply.runCommand(`ov_fortwars_spawn ${choice}`);-- reply(ply, `Trying Fort Wars prop: ${choice}`);++ reply(ply, `Placed: ${choice}`);return false;});}@@@ -17,13 +52,102 @@const GM = {mode: "fortwars",name: "OpenVibe Fort Wars",-- Initialize() { OV.log("Fort Wars Initialize fired"); registerCommands(); },-- PlayerInitialSpawn(ply) { ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate"); },++ roundDuration: BUILD_DURATION + FIGHT_DURATION,++ countdownDuration: 10,++++ Initialize() {++ OV.log("Fort Wars Initialize fired");++ registerCommands();++ },++++ MapInitialize(mapName) {++ OV.log(`Fort Wars MapInitialize: ${mapName}`);++ this.scheduleRoundStart();++ },++++ PlayerInitialSpawn(ply) {++ ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate");++ },++++ startRound() {++ this._roundNumber += 1;++ this._roundState = "active";++ phase = "build";++++ assignTeams();++++ OV.log(`[FW] RoundStart round=${this._roundNumber}`);++ hook.Run("RoundStart", this._roundNumber);++ OV.broadcast(`Round ${this._roundNumber} — BUILD PHASE! You have ${BUILD_DURATION}s to fortify!`);++ OV.serverCommand("ov_fortwars_build_enabled 1");++++ const self = this;++ const roundNum = this._roundNumber;++++ // Transition to fight phase++ timer.simple(BUILD_DURATION, function () {++ if (self._roundState !== "active" || self._roundNumber !== roundNum) return;++ phase = "fight";++ OV.broadcast("BUILD PHASE OVER! FIGHT!");++ OV.serverCommand("ov_fortwars_build_enabled 0");++ hook.Run("FW_FightPhaseStart", roundNum);++ });++++ // End of round timer++ timer.create(`ov_fw_round_end_${roundNum}`, BUILD_DURATION + FIGHT_DURATION, 1, function () {++ if (self._roundState === "active" && self._roundNumber === roundNum) {++ if (redAlive > 0 && blueAlive === 0) {++ self.endRound("red_win");++ } else if (blueAlive > 0 && redAlive === 0) {++ self.endRound("blue_win");++ } else {++ self.endRound("draw");++ }++ }++ });++ },++++ endRound(reason) {++ if (this._roundState !== "active") return;++ this._roundState = "ended";++ timer.remove(`ov_fw_round_end_${this._roundNumber}`);++ phase = "build";++ OV.serverCommand("ov_fortwars_build_enabled 1");++++ const msg = reason === "red_win" ? "Red team wins!" :++ reason === "blue_win" ? "Blue team wins!" :++ "Draw! Both teams survived.";++++ OV.log(`[FW] RoundEnd round=${this._roundNumber} reason=${reason}`);++ hook.Run("RoundEnd", this._roundNumber, reason);++ OV.broadcast(msg);++++ this.scheduleRoundStart();++ },++++ PlayerDeath(victim, _attacker) {++ if (!victim || this._roundState !== "active" || phase !== "fight") return;++ if (victim.team() === TEAM_RED) redAlive = Math.max(0, redAlive - 1);++ if (victim.team() === TEAM_BLUE) blueAlive = Math.max(0, blueAlive - 1);++++ if (redAlive === 0 && blueAlive > 0) {++ this.endRound("blue_win");++ } else if (blueAlive === 0 && redAlive > 0) {++ this.endRound("red_win");++ } else if (redAlive === 0 && blueAlive === 0) {++ this.endRound("draw");++ }++ },++Think() {}};hook.Add("PlayerSpawn", "OpenVibeFortWarsSpawnTip", function (ply) {-- ply.chat("Build phase: use !build crate, !build pallet, or !build fence.");++ if (phase === "build") {++ ply.chat(`Build phase: use !build crate, !build pallet, or !build fence. (${BUILD_DURATION}s)`);++ } else {++ ply.chat("Fight phase — destroy the enemy fort!");++ }});gamemode.set(GM);@@@ -1,10 +1,53 @@(function () {++ // Prop Hunt round state++ // Teams: PROPS (team 2) vs HUNTERS (team 3)++ // Phase 1: props scatter for HUNTER_LOCK_DURATION while hunters are locked.++ // Phase 2: hunters are released, full round timer runs.++ // Win conditions:++ // - All props eliminated → hunters win++ // - Timer runs out with ≥1 prop alive → props win++++ const HUNTER_LOCK_DURATION = 20; // seconds hunters wait at round start++ const ROUND_DURATION = 180; // total round time in seconds++++ const TEAM_PROPS = 2;++ const TEAM_HUNTERS = 3;++++ let propsAlive = 0;++ let huntersAlive = 0;++ let locked = false;++++ function countAlive() {++ const players = OV.players();++ propsAlive = players.filter((p) => p.team() === TEAM_PROPS).length;++ huntersAlive = players.filter((p) => p.team() === TEAM_HUNTERS).length;++ }++++ function assignTeams() {++ const players = OV.players();++ if (players.length === 0) return;++++ // 1 hunter per 3 props, minimum 1 hunter++ const hunterCount = Math.max(1, Math.floor(players.length / 4));++ const shuffled = players.slice().sort(() => Math.random() - 0.5);++++ shuffled.forEach(function (p, i) {++ p.setTeam(i < hunterCount ? TEAM_HUNTERS : TEAM_PROPS);++ });++++ countAlive();++ OV.broadcast(`Teams set: ${propsAlive} props vs ${huntersAlive} hunters.`);++ }++function registerCommands() {if (!globalThis.command) return;++command.add("ph_status", "Show Prop Hunt status", function ({ ply, reply }) {-- reply(ply, `Prop Hunt JS online. map=${OV.getMapName()} players=${OV.players().length}`);++ const gm = gamemode.get();++ reply(ply, `Prop Hunt | round=${gm._roundNumber} state=${gm._roundState} props=${propsAlive} hunters=${huntersAlive} locked=${locked}`);return false;});++command.add("disguise", "Disguise as an allowlisted prop", function ({ args, ply, reply }) {const choice = args[0] || "crate";if (!ply) return false;@@@ -17,12 +60,104 @@const GM = {mode: "prophunt",name: "OpenVibe Prop Hunt",-- Initialize() { OV.log("Prop Hunt Initialize fired"); registerCommands(); },-- PlayerInitialSpawn(ply) { ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate"); },++ roundDuration: ROUND_DURATION,++ countdownDuration: 10,++++ Initialize() {++ OV.log("Prop Hunt Initialize fired");++ registerCommands();++ },++++ MapInitialize(mapName) {++ OV.log(`Prop Hunt MapInitialize: ${mapName}`);++ this.scheduleRoundStart();++ },++++ PlayerInitialSpawn(ply) {++ ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate");++ },++++ startRound() {++ this._roundNumber += 1;++ this._roundState = "active";++ locked = true;++++ assignTeams();++++ OV.log(`[PH] RoundStart round=${this._roundNumber}`);++ hook.Run("RoundStart", this._roundNumber);++ OV.broadcast(`Round ${this._roundNumber} — props, hide now! Hunters release in ${HUNTER_LOCK_DURATION}s.`);++++ // Lock hunters, release after delay++ const self = this;++ const roundNum = this._roundNumber;++ timer.simple(HUNTER_LOCK_DURATION, function () {++ if (self._roundState !== "active" || self._roundNumber !== roundNum) return;++ locked = false;++ OV.broadcast("Hunters released! Find the props!");++ });++++ // Full round timer++ timer.create(`ov_ph_round_end_${roundNum}`, ROUND_DURATION, 1, function () {++ if (self._roundState === "active" && self._roundNumber === roundNum) {++ if (propsAlive > 0) {++ self.endRound("props_win");++ } else {++ self.endRound("hunters_win");++ }++ }++ });++ },++++ endRound(reason) {++ if (this._roundState !== "active") return;++ this._roundState = "ended";++ locked = false;++ timer.remove(`ov_ph_round_end_${this._roundNumber}`);++++ let msg;++ if (reason === "props_win") {++ msg = "Props survived! Props win!";++ } else if (reason === "hunters_win") {++ msg = "All props found! Hunters win!";++ } else {++ msg = `Round ${this._roundNumber} ended.`;++ }++++ OV.log(`[PH] RoundEnd round=${this._roundNumber} reason=${reason}`);++ hook.Run("RoundEnd", this._roundNumber, reason);++ OV.broadcast(msg);++++ this.scheduleRoundStart();++ },++++ PlayerDeath(victim, _attacker) {++ if (!victim) return;++ countAlive();++ if (this._roundState !== "active") return;++++ if (victim.team() === TEAM_PROPS) {++ if (propsAlive === 0) {++ this.endRound("hunters_win");++ } else {++ OV.broadcast(`A prop was found! ${propsAlive} prop(s) remaining.`);++ }++ }++ },++Think() {}};++hook.Add("PlayerSpawn", "OpenVibePropHuntSpawnTip", function (ply) {-- ply.chat("Blend in. Use !disguise crate, !disguise barrel, or !disguise chair.");++ if (locked && ply.team() === TEAM_HUNTERS) {++ ply.chat("Wait — hunters are locked until props hide.");++ return;++ }++ if (ply.team() === TEAM_PROPS) {++ ply.chat("Hide! Use !disguise crate, !disguise barrel, or !disguise chair.");++ } else {++ ply.chat("Hunt the props down!");++ }});gamemode.set(GM);@@@ -1,8 +1,61 @@(function () {++ // Traitor Town round state++ // Roles: INNOCENT (team 2) vs TRAITOR (team 3)++ // ~1 traitor per 4 players, minimum 1.++ // Win conditions:++ // - All traitors dead → innocents win++ // - All innocents dead → traitors win++ // - Timer runs out with traitors alive → traitors win++++ const ROUND_DURATION = 300; // 5 minutes++++ const TEAM_INNOCENT = 2;++ const TEAM_TRAITOR = 3;++++ let innocentsAlive = 0;++ let traitorsAlive = 0;++ let rolesAssigned = false;++++ function assignRoles() {++ const players = OV.players();++ if (players.length < 2) return;++++ rolesAssigned = false;++ const shuffled = players.slice().sort(() => Math.random() - 0.5);++ const traitorCount = Math.max(1, Math.floor(shuffled.length / 4));++++ shuffled.forEach(function (p, i) {++ p.setTeam(i < traitorCount ? TEAM_TRAITOR : TEAM_INNOCENT);++ });++++ innocentsAlive = shuffled.filter((p) => p.team() === TEAM_INNOCENT).length;++ traitorsAlive = shuffled.filter((p) => p.team() === TEAM_TRAITOR).length;++ rolesAssigned = true;++++ // Tell traitors who they are (privately via chat)++ shuffled.filter((p) => p.team() === TEAM_TRAITOR).forEach(function (p) {++ p.chat("[TRAITOR] You are a traitor. Eliminate all innocents.");++ });++++ OV.broadcast(`Round ${gamemode.get()._roundNumber} — ${players.length} players. Find the traitor(s)!`);++ }++function registerCommands() {if (!globalThis.command) return;++command.add("ttt_status", "Show Traitor Town status", function ({ ply, reply }) {-- reply(ply, `Traitor Town JS online. map=${OV.getMapName()} players=${OV.players().length}`);++ const gm = gamemode.get();++ reply(ply, `Traitor Town | round=${gm._roundNumber} state=${gm._roundState} innocents=${innocentsAlive} traitors=${traitorsAlive}`);++ return false;++ });++++ command.add("role", "Show your current role", function ({ ply, reply }) {++ if (!ply || !rolesAssigned) {++ reply(ply, "Roles have not been assigned yet.");++ return false;++ }++ const role = ply.team() === TEAM_TRAITOR ? "TRAITOR" : "INNOCENT";++ reply(ply, `Your role: ${role}`);return false;});}@@@ -10,13 +63,86 @@const GM = {mode: "traitortown",name: "OpenVibe Traitor Town",-- Initialize() { OV.log("Traitor Town Initialize fired"); registerCommands(); },-- PlayerInitialSpawn(ply) { ply.chat("Traitor Town JS loaded. Try !ttt_status"); },++ roundDuration: ROUND_DURATION,++ countdownDuration: 15,++++ Initialize() {++ OV.log("Traitor Town Initialize fired");++ registerCommands();++ },++++ MapInitialize(mapName) {++ OV.log(`Traitor Town MapInitialize: ${mapName}`);++ this.scheduleRoundStart();++ },++++ PlayerInitialSpawn(ply) {++ ply.chat("Traitor Town JS loaded. Try !ttt_status or !role");++ },++++ startRound() {++ this._roundNumber += 1;++ this._roundState = "active";++ rolesAssigned = false;++ innocentsAlive = 0;++ traitorsAlive = 0;++++ assignRoles();++++ OV.log(`[TTT] RoundStart round=${this._roundNumber} traitors=${traitorsAlive} innocents=${innocentsAlive}`);++ hook.Run("RoundStart", this._roundNumber);++++ const self = this;++ const roundNum = this._roundNumber;++ timer.create(`ov_ttt_round_end_${roundNum}`, ROUND_DURATION, 1, function () {++ if (self._roundState === "active" && self._roundNumber === roundNum) {++ // Traitors win if still alive at time++ self.endRound(traitorsAlive > 0 ? "traitors_win" : "innocents_win");++ }++ });++ },++++ endRound(reason) {++ if (this._roundState !== "active") return;++ this._roundState = "ended";++ rolesAssigned = false;++ timer.remove(`ov_ttt_round_end_${this._roundNumber}`);++++ const msg = reason === "traitors_win" ? "Traitors win! The innocents never suspected a thing." :++ reason === "innocents_win" ? "Innocents win! All traitors have been identified." :++ `Round ${this._roundNumber} ended.`;++++ OV.log(`[TTT] RoundEnd round=${this._roundNumber} reason=${reason}`);++ hook.Run("RoundEnd", this._roundNumber, reason);++ OV.broadcast(msg);++++ this.scheduleRoundStart();++ },++++ PlayerDeath(victim, _attacker) {++ if (!victim || this._roundState !== "active" || !rolesAssigned) return;++++ if (victim.team() === TEAM_INNOCENT) {++ innocentsAlive = Math.max(0, innocentsAlive - 1);++ OV.broadcast(`An innocent has died. ${innocentsAlive} innocent(s) remaining.`);++ if (innocentsAlive === 0) this.endRound("traitors_win");++ } else if (victim.team() === TEAM_TRAITOR) {++ traitorsAlive = Math.max(0, traitorsAlive - 1);++ OV.broadcast(`A traitor has been found! ${traitorsAlive} traitor(s) remaining.`);++ if (traitorsAlive === 0) this.endRound("innocents_win");++ }++ },++Think() {}};hook.Add("PlayerSpawn", "OpenVibeTraitorTownSpawnTip", function (ply) {-- ply.chat("Watch the room. Someone here is lying.");++ if (rolesAssigned) {++ const role = ply.team() === TEAM_TRAITOR ? "TRAITOR" : "innocent";++ ply.chat(`Your role: ${role.toUpperCase()}. Type !role to check.`);++ } else {++ ply.chat("Watch the room. Someone here is lying. Type !role once the round starts.");++ }});gamemode.set(GM);@@@ -80,6 +80,44 @@ static ConCommand ov_js_cmd("Send a ConsoleCommand event into the embedded OpenVibe JavaScript runtime.",FCVAR_GAMEDLL);++++void OpenVibeJS_Server_RoundStart(int roundNumber)++{++ Msg("[OV JS] Windows server CI stub: RoundStart round=%d\n", roundNumber);++}++++void OpenVibeJS_Server_RoundEnd(int roundNumber, const char *reason)++{++ Msg("[OV JS] Windows server CI stub: RoundEnd round=%d reason=%s\n",++ roundNumber, reason ? reason : "");++}++++static void OV_RoundStart_f(const CCommand &args)++{++ const int round = args.ArgC() >= 2 ? Q_atoi(args[1]) : 1;++ OpenVibeJS_Server_RoundStart(round);++}++++static ConCommand ov_round_start(++ "ov_round_start",++ OV_RoundStart_f,++ "Fire the RoundStart hook into the OpenVibe JavaScript runtime.",++ FCVAR_GAMEDLL++);++++static void OV_RoundEnd_f(const CCommand &args)++{++ const int round = args.ArgC() >= 2 ? Q_atoi(args[1]) : 1;++ const char *pszReason = args.ArgC() >= 3 ? args[2] : "time";++ OpenVibeJS_Server_RoundEnd(round, pszReason);++}++++static ConCommand ov_round_end(++ "ov_round_end",++ OV_RoundEnd_f,++ "Fire the RoundEnd hook into the OpenVibe JavaScript runtime. Usage: ov_round_end <round> [reason]",++ FCVAR_GAMEDLL++);#else#include "hl2mp_player.h"#include "openvibe_js_server.h"@@@ -347,4 +385,65 @@ static ConCommand ov_js_cmd("Send a ConsoleCommand event into the embedded OpenVibe JavaScript runtime.",FCVAR_GAMEDLL);++++void OpenVibeJS_Server_RoundStart( int roundNumber )++{++ OpenVibeJS_EnsureStarted();++++ if ( !OpenVibeJS_IsRunning() )++ return;++++ JSContext *ctx = g_OVServerJS.Context();++ JSValue jsRound = JS_NewInt32( ctx, roundNumber );++ JSValueConst argv[] = { jsRound };++ g_OVServerJS.CallHookVoid( "RoundStart", 1, argv );++ JS_FreeValue( ctx, jsRound );++++ Msg( "[OV JS] RoundStart fired round=%d\n", roundNumber );++}++++void OpenVibeJS_Server_RoundEnd( int roundNumber, const char *reason )++{++ OpenVibeJS_EnsureStarted();++++ if ( !OpenVibeJS_IsRunning() )++ return;++++ JSContext *ctx = g_OVServerJS.Context();++ JSValue jsRound = JS_NewInt32( ctx, roundNumber );++ JSValue jsReason = JS_NewString( ctx, reason ? reason : "time" );++ JSValueConst argv[] = { jsRound, jsReason };++ g_OVServerJS.CallHookVoid( "RoundEnd", 2, argv );++ JS_FreeValue( ctx, jsRound );++ JS_FreeValue( ctx, jsReason );++++ Msg( "[OV JS] RoundEnd fired round=%d reason=%s\n", roundNumber, reason ? reason : "time" );++}++++static void OV_RoundStart_f( const CCommand &args )++{++ const int round = args.ArgC() >= 2 ? Q_atoi( args[1] ) : 1;++ OpenVibeJS_Server_RoundStart( round );++}++++static ConCommand ov_round_start(++ "ov_round_start",++ OV_RoundStart_f,++ "Fire the RoundStart hook into the OpenVibe JavaScript runtime.",++ FCVAR_GAMEDLL++);++++static void OV_RoundEnd_f( const CCommand &args )++{++ const int round = args.ArgC() >= 2 ? Q_atoi( args[1] ) : 1;++ const char *pszReason = args.ArgC() >= 3 ? args[2] : "time";++ OpenVibeJS_Server_RoundEnd( round, pszReason );++}++++static ConCommand ov_round_end(++ "ov_round_end",++ OV_RoundEnd_f,++ "Fire the RoundEnd hook into the OpenVibe JavaScript runtime. Usage: ov_round_end <round> [reason]",++ FCVAR_GAMEDLL++);#endif@@@ -14,5 +14,8 @@ void OpenVibeJS_Server_PlayerDeath(CHL2MP_Player *victim, CBaseEntity *attacker,void OpenVibeJS_Server_PlayerDisconnected(CHL2MP_Player *player);bool OpenVibeJS_Server_PlayerSay(CHL2MP_Player *player, const char *text);++void OpenVibeJS_Server_RoundStart(int roundNumber);++void OpenVibeJS_Server_RoundEnd(int roundNumber, const char *reason);++void OpenVibeJS_Server_ConsoleCommand(const char *text);void OpenVibeJS_Server_FireHook(const char *hookName);
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
| Component | Status | Notes |
| Component | Status | Notes |
| --- | --- | --- |
| --- | --- | --- |
-| Backend API | Complete for MVP | Dev auth, Steam ticket auth route, inventory, shop, travel, rewards, leaderboard, asset manifest. |
+| Backend API | Complete for MVP | Dev auth, Steam ticket auth route, inventory, shop, travel, rewards, leaderboard, asset manifest, batch match rewards. |
| PostgreSQL schema | Complete for MVP | Durable players, currency, inventory, servers, join tokens, match results. |
| PostgreSQL schema | Complete for MVP | Durable players, currency, inventory, servers, join tokens, match results. |
| Redis sessions | Implemented | Enabled when `SESSION_REDIS_URL` is set. |
| Redis sessions | Implemented | Enabled when `SESSION_REDIS_URL` is set. |
| C++ client travel | Implemented | `ov_join <mode>` calls `/v1/travel/request` and connects with token. |
| C++ client travel | Implemented | `ov_join <mode>` calls `/v1/travel/request` and connects with token. |
@@ -42,6 +42,9 @@ openvibe-source/
@@ -42,6 +42,9 @@ openvibe-source/
| Fort Wars placement | Implemented | `ov_fortwars_spawn` |
| Fort Wars placement | Implemented | `ov_fortwars_spawn` |
| VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |
| VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |
| Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes |
| Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes |
+| JS round system | Implemented | Base round state machine; Prop Hunt, Deathrun, Fort Wars, Traitor Town each with teams, timers, win conditions |
+| Batch match rewards | Implemented | `POST /v1/matches/end/batch` — rewards multiple players in one idempotent call |
+| C++ RoundStart/RoundEnd | Implemented | `OpenVibeJS_Server_RoundStart/End`, `ov_round_start`, `ov_round_end` ConCommands |
| Maps | Compiled | `game/openvibe.games/maps/*.bsp` |
| Maps | Compiled | `game/openvibe.games/maps/*.bsp` |
| Production infra | Scaffolded | `infra/docker-compose.yml`, `infra/production/docker-stack.yml`, `infra/kubernetes/openvibe.yaml` |
| Production infra | Scaffolded | `infra/docker-compose.yml`, `infra/production/docker-stack.yml`, `infra/kubernetes/openvibe.yaml` |
@@ -89,10 +92,10 @@ node tools/smoke-api.mjs
@@ -89,10 +92,10 @@ node tools/smoke-api.mjs
tools/dev-down.sh
tools/dev-down.sh
```
```
-## Verified On June 29, 2026
+## Verified On June 30, 2026
- Backend TypeScript build passed.
- Backend TypeScript build passed.
-- Backend Vitest suite passed: 12 tests.
+- Backend Vitest suite passed: 13 tests.
- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.
- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.
- SRCDS smoke test passed for all five maps.
- SRCDS smoke test passed for all five maps.
- Full dev stack registered all five servers and returned valid travel targets for every mode.
- Full dev stack registered all five servers and returned valid travel targets for every mode.
@@ -538,4 +538,61 @@ describe("OpenVibe API vertical slice", () => {
@@ -538,4 +538,61 @@ describe("OpenVibe API vertical slice", () => {
await app.close();
await app.close();
});
});
+
+ it("batch match rewards: rewards multiple players idempotently and rejects wrong secret", async () => {
+ const app = await testApp();
+ const player2 = "76561198000000010";
+ const player3 = "76561198000000011";
+
+ // Register players
+ await app.inject({ method: "POST", url: "/v1/auth/dev",
+ payload: { steamId, displayName: "Batch Player 1" } });
+ await app.inject({ method: "POST", url: "/v1/auth/dev",
+ payload: { steamId: player2, displayName: "Batch Player 2" } });
+ await app.inject({ method: "POST", url: "/v1/auth/dev",
+ payload: { steamId: player3, displayName: "Batch Player 3" } });
+
+ // Register server
+ await app.inject({ method: "POST", url: "/v1/servers/register",
+ payload: { serverId: "local-prophunt-batch", serverSecret, mode: "prophunt",
+ mapName: "ph_openvibe_dev", publicHost: "127.0.0.1", port: 27099, maxPlayers: 24 } });
+
+ const batchPayload = {
+ matchId: "prophunt-round-batch-1",
+ serverId: "local-prophunt-batch",
+ serverSecret,
+ mode: "prophunt",
+ results: [
+ { steamId, rewardCurrency: 50, rewardXp: 100, stats: { winner: true } },
+ { steamId: player2, rewardCurrency: 25, rewardXp: 50 },
+ { steamId: player3, rewardCurrency: 10, rewardXp: 20 },
+ ],
+ };
+
+ // Wrong secret is rejected
+ const badSecret = await app.inject({
+ method: "POST",
+ url: "/v1/matches/end/batch",
+ payload: { ...batchPayload, serverSecret: "wrong-secret" },
+ });
+ expect(badSecret.statusCode).toBe(403);
+
+ // First batch succeeds
+ const first = await app.inject({ method: "POST", url: "/v1/matches/end/batch", payload: batchPayload });
+ expect(first.statusCode).toBe(200);
+ expect(first.json().rewarded).toBe(3);
+ expect(first.json().profiles).toHaveLength(3);
+
+ // Find player 1's profile in results
+ const p1 = first.json().profiles.find((p: { player: { steamId: string } }) => p.player.steamId === steamId);
+ expect(p1.player.currencyBalance).toBe(300); // 250 seed + 50
+ expect(p1.player.xp).toBe(100);
+
+ // Duplicate batch is idempotent (no double rewards)
+ const second = await app.inject({ method: "POST", url: "/v1/matches/end/batch", payload: batchPayload });
+ expect(second.statusCode).toBe(200);
+ expect(second.json().rewarded).toBe(0);
+
+ await app.close();
+ });
});
});
@@ -8,6 +8,7 @@ import {
@@ -8,6 +8,7 @@ import {
acceptPartyInviteSchema,
acceptPartyInviteSchema,
auditEventSchema,
auditEventSchema,
auditQuerySchema,
auditQuerySchema,
+ batchMatchEndSchema,
buyItemSchema,
buyItemSchema,
createPartySchema,
createPartySchema,
equipItemSchema,
equipItemSchema,
@@ -277,6 +278,13 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
@@ -277,6 +278,13 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
return profile;
return profile;
});
});
+ app.post("/v1/matches/end/batch", async (request, reply) => {
+ const body = batchMatchEndSchema.parse(request.body);
+ const profiles = await options.repository.recordBatchMatchRewards(body);
+ if (profiles === null) return reply.code(403).send({ error: "invalid_server_secret" });
+ return { rewarded: profiles.length, profiles };
+ });
+
// GET /v1/leaderboard?limit=10&mode=prophunt
// GET /v1/leaderboard?limit=10&mode=prophunt
app.get("/v1/leaderboard", async (request) => {
app.get("/v1/leaderboard", async (request) => {
const query = leaderboardQuerySchema.parse(request.query);
const query = leaderboardQuerySchema.parse(request.query);
@@ -194,6 +194,19 @@ export interface MatchRewardInput {
@@ -194,6 +194,19 @@ export interface MatchRewardInput {
stats?: Record<string, unknown>;
stats?: Record<string, unknown>;
}
}
+export interface BatchMatchRewardInput {
+ matchId: string;
+ serverId: string;
+ serverSecret: string;
+ mode: GameMode;
+ results: Array<{
+ steamId: string;
+ rewardCurrency: number;
+ rewardXp: number;
+ stats?: Record<string, unknown>;
+ }>;
+}
+
export interface UpsertShopItemInput {
export interface UpsertShopItemInput {
itemId: string;
itemId: string;
itemType: ItemType;
itemType: ItemType;
@@ -233,6 +246,7 @@ export interface OpenVibeRepository {
@@ -233,6 +246,7 @@ export interface OpenVibeRepository {
serverId: string;
serverId: string;
}): Promise<JoinTokenValidation>;
}): Promise<JoinTokenValidation>;
recordMatchReward(input: MatchRewardInput): Promise<PlayerProfile | null>;
recordMatchReward(input: MatchRewardInput): Promise<PlayerProfile | null>;
+ recordBatchMatchRewards(input: BatchMatchRewardInput): Promise<PlayerProfile[] | null>;
getLeaderboard(options: { limit: number; mode?: GameMode }): Promise<LeaderboardEntry[]>;
getLeaderboard(options: { limit: number; mode?: GameMode }): Promise<LeaderboardEntry[]>;
upsertShopItem(input: UpsertShopItemInput): Promise<ShopItem>;
upsertShopItem(input: UpsertShopItemInput): Promise<ShopItem>;
recordAuditEvent(input: {
recordAuditEvent(input: {
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
import { nanoid } from "nanoid";
import { nanoid } from "nanoid";
import {
import {
+ BatchMatchRewardInput,
GameMode,
GameMode,
GameServer,
GameServer,
HeartbeatInput,
HeartbeatInput,
@@ -354,6 +355,27 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
@@ -354,6 +355,27 @@ export class MemoryOpenVibeRepository implements OpenVibeRepository {
return this.profileFor(input.steamId);
return this.profileFor(input.steamId);
}
}
+ async recordBatchMatchRewards(input: BatchMatchRewardInput): Promise<PlayerProfile[] | null> {
+ const server = this.servers.get(input.serverId);
+ if (!server || server.serverSecret !== input.serverSecret) return null;
+
+ const profiles: PlayerProfile[] = [];
+ for (const entry of input.results) {
+ const player = this.players.get(entry.steamId);
+ if (!player) continue;
+
+ const key = `${input.matchId}:${entry.steamId}`;
+ if (!this.rewardKeys.has(key)) {
+ player.currencyBalance += entry.rewardCurrency;
+ player.xp += entry.rewardXp;
+ this.rewardKeys.add(key);
+ profiles.push(this.profileFor(entry.steamId));
+ }
+ }
+
+ return profiles;
+ }
+
private profileFor(steamId: string): PlayerProfile {
private profileFor(steamId: string): PlayerProfile {
const player = this.requirePlayer(steamId);
const player = this.requirePlayer(steamId);
const owned = this.ensureInventory(steamId);
const owned = this.ensureInventory(steamId);
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
import { Pool } from "pg";
import { Pool } from "pg";
import {
import {
+ BatchMatchRewardInput,
GameMode,
GameMode,
GameServer,
GameServer,
HeartbeatInput,
HeartbeatInput,
@@ -576,6 +577,84 @@ export class PgOpenVibeRepository implements OpenVibeRepository {
@@ -576,6 +577,84 @@ export class PgOpenVibeRepository implements OpenVibeRepository {
return this.getProfile(input.steamId);
return this.getProfile(input.steamId);
}
}
+ async recordBatchMatchRewards(input: BatchMatchRewardInput): Promise<PlayerProfile[] | null> {
+ const client = await this.pool.connect();
+ const rewarded: string[] = [];
+
+ try {
+ await client.query("BEGIN");
+
+ const serverResult = await client.query(
+ "SELECT 1 FROM game_servers WHERE server_id = $1 AND server_secret = $2",
+ [input.serverId, input.serverSecret],
+ );
+ if (serverResult.rowCount !== 1) {
+ await client.query("COMMIT");
+ return null;
+ }
+
+ for (const entry of input.results) {
+ const inserted = await client.query(
+ `
+ INSERT INTO match_results (
+ match_id, server_id, steam_id, mode, reward_currency, reward_xp, stats
+ )
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb)
+ ON CONFLICT (match_id, steam_id) DO NOTHING
+ RETURNING result_id
+ `,
+ [
+ input.matchId,
+ input.serverId,
+ entry.steamId,
+ input.mode,
+ entry.rewardCurrency,
+ entry.rewardXp,
+ JSON.stringify(entry.stats ?? {}),
+ ],
+ );
+
+ if (inserted.rowCount === 1) {
+ await client.query(
+ `
+ UPDATE players
+ SET currency_balance = currency_balance + $2,
+ xp = xp + $3,
+ updated_at = now()
+ WHERE steam_id = $1
+ `,
+ [entry.steamId, entry.rewardCurrency, entry.rewardXp],
+ );
+
+ await client.query(
+ `
+ INSERT INTO currency_ledger (steam_id, delta, reason, idempotency_key)
+ VALUES ($1, $2, 'match_reward', $3)
+ ON CONFLICT DO NOTHING
+ `,
+ [entry.steamId, entry.rewardCurrency, `match:${input.matchId}:${entry.steamId}`],
+ );
+
+ rewarded.push(entry.steamId);
+ }
+ }
+
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ } finally {
+ client.release();
+ }
+
+ const profiles: PlayerProfile[] = [];
+ for (const steamId of rewarded) {
+ const profile = await this.getProfile(steamId);
+ if (profile) profiles.push(profile);
+ }
+ return profiles;
+ }
+
async getLeaderboard(options: { limit: number; mode?: GameMode }): Promise<LeaderboardEntry[]> {
async getLeaderboard(options: { limit: number; mode?: GameMode }): Promise<LeaderboardEntry[]> {
const result = await this.pool.query(
const result = await this.pool.query(
`
`
@@ -98,6 +98,24 @@ export const matchEndSchema = z.object({
@@ -98,6 +98,24 @@ export const matchEndSchema = z.object({
stats: z.record(z.string(), z.unknown()).optional(),
stats: z.record(z.string(), z.unknown()).optional(),
});
});
+export const batchMatchEndSchema = z.object({
+ matchId: z.string().min(3).max(120),
+ serverId,
+ serverSecret,
+ mode,
+ results: z
+ .array(
+ z.object({
+ steamId,
+ rewardCurrency: z.number().int().min(0).max(5000),
+ rewardXp: z.number().int().min(0).max(50000),
+ stats: z.record(z.string(), z.unknown()).optional(),
+ }),
+ )
+ .min(1)
+ .max(64),
+});
+
export const leaderboardQuerySchema = z.object({
export const leaderboardQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
limit: z.coerce.number().int().min(1).max(100).default(10),
mode: mode.optional(),
mode: mode.optional(),
@@ -204,6 +204,11 @@ EntityTakeDamage(entity, damageInfo)
@@ -204,6 +204,11 @@ EntityTakeDamage(entity, damageInfo)
PlayerUse(ply, entity)
PlayerUse(ply, entity)
PlayerCanPickupWeapon(ply, weapon)
PlayerCanPickupWeapon(ply, weapon)
PlayerLoadout(ply)
PlayerLoadout(ply)
-RoundStart
-RoundEnd
+```
+
+Already added via `ov_round_start` / `ov_round_end` ConCommands and direct C++ bridge calls:
+
+```text
+RoundStart(roundNumber)
+RoundEnd(roundNumber, reason)
```
```
@@ -1,14 +1,30 @@
@@ -1,14 +1,30 @@
(function () {
(function () {
+ // Round state: "idle" → "countdown" → "active" → "ended"
+ // Override startRound() and endRound() in submode gamemodes for custom logic.
+ // RoundStart and RoundEnd hooks fire at the appropriate transitions.
+
+ const DEFAULT_ROUND_DURATION = 300; // 5 minutes
+ const DEFAULT_COUNTDOWN = 10; // 10-second warmup
+
const GM = {
const GM = {
mode: "base",
mode: "base",
name: "OpenVibe Base",
name: "OpenVibe Base",
+ // Round settings — override in submode
+ roundDuration: DEFAULT_ROUND_DURATION,
+ countdownDuration: DEFAULT_COUNTDOWN,
+
+ // Round tracking (do not override)
+ _roundState: "idle", // "idle" | "countdown" | "active" | "ended"
+ _roundNumber: 0,
+
Initialize() {
Initialize() {
OV.log("Base Initialize fired");
OV.log("Base Initialize fired");
},
},
MapInitialize(mapName) {
MapInitialize(mapName) {
OV.log(`Base MapInitialize: ${mapName}`);
OV.log(`Base MapInitialize: ${mapName}`);
+ this.scheduleRoundStart();
},
},
PlayerInitialSpawn(ply) {
PlayerInitialSpawn(ply) {
@@ -27,6 +43,57 @@
@@ -27,6 +43,57 @@
return undefined;
return undefined;
},
},
+ // Called when the countdown timer ends — fires "RoundStart" and runs round timer.
+ startRound() {
+ this._roundNumber += 1;
+ this._roundState = "active";
+ OV.log(`[OV Round] RoundStart round=${this._roundNumber}`);
+ hook.Run("RoundStart", this._roundNumber);
+ OV.broadcast(`Round ${this._roundNumber} — begin!`);
+
+ const roundDuration = this.roundDuration;
+ const roundNumber = this._roundNumber;
+ const self = this;
+ timer.create(`ov_round_end_${roundNumber}`, roundDuration, 1, function () {
+ if (self._roundState === "active" && self._roundNumber === roundNumber) {
+ self.endRound("time");
+ }
+ });
+ },
+
+ // Called to end the current active round. reason: "time" | "win" | "loss" | "draw"
+ endRound(reason) {
+ if (this._roundState !== "active") return;
+ this._roundState = "ended";
+ OV.log(`[OV Round] RoundEnd round=${this._roundNumber} reason=${reason}`);
+ hook.Run("RoundEnd", this._roundNumber, reason || "time");
+ OV.broadcast(`Round ${this._roundNumber} ended (${reason || "time"}).`);
+ timer.remove(`ov_round_end_${this._roundNumber}`);
+ this.scheduleRoundStart();
+ },
+
+ // Begin countdown then start round.
+ scheduleRoundStart() {
+ const players = OV.players ? OV.players() : [];
+ if (players.length === 0) {
+ // No players — wait and retry
+ this._roundState = "idle";
+ const self = this;
+ timer.simple(5, function () { self.scheduleRoundStart(); });
+ return;
+ }
+
+ this._roundState = "countdown";
+ const cd = this.countdownDuration;
+ OV.broadcast(`Next round in ${cd} seconds.`);
+ const self = this;
+ timer.simple(cd, function () {
+ if (self._roundState === "countdown") {
+ self.startRound();
+ }
+ });
+ },
+
Think() {}
Think() {}
};
};
@@ -1,8 +1,46 @@
@@ -1,8 +1,46 @@
(function () {
(function () {
+ // Deathrun round state
+ // Teams: RUNNERS (team 2) vs ACTIVATORS (team 3)
+ // One activator controls traps; all others are runners.
+ // Win conditions:
+ // - All runners die → activator wins
+ // - Any runner reaches the finish (fires "DR_RunnerFinished") → runners win
+ // - Timer runs out with ≥1 runner alive → runners win
+
+ const ROUND_DURATION = 240; // 4 minutes per round
+
+ const TEAM_RUNNERS = 2;
+ const TEAM_ACTIVATORS = 3;
+
+ let runnersAlive = 0;
+ let activatorId = null; // userId of the activator this round
+
+ function assignTeams() {
+ const players = OV.players();
+ if (players.length === 0) return;
+
+ const shuffled = players.slice().sort(() => Math.random() - 0.5);
+ // Pick one activator (rotate by round number)
+ const activatorIdx = (gamemode.get()._roundNumber - 1) % shuffled.length;
+ shuffled.forEach(function (p, i) {
+ if (i === activatorIdx) {
+ p.setTeam(TEAM_ACTIVATORS);
+ activatorId = p.userId();
+ } else {
+ p.setTeam(TEAM_RUNNERS);
+ }
+ });
+
+ runnersAlive = players.filter((p) => p.team() === TEAM_RUNNERS).length;
+ const activator = players.find((p) => p.userId() === activatorId);
+ OV.broadcast(`Deathrun: ${runnersAlive} runner(s) vs activator ${activator ? activator.name() : "?"}.`);
+ }
+
function registerCommands() {
function registerCommands() {
if (!globalThis.command) return;
if (!globalThis.command) return;
command.add("dr_status", "Show Deathrun status", function ({ ply, reply }) {
command.add("dr_status", "Show Deathrun status", function ({ ply, reply }) {
- reply(ply, `Deathrun JS online. map=${OV.getMapName()} players=${OV.players().length}`);
+ const gm = gamemode.get();
+ reply(ply, `Deathrun | round=${gm._roundNumber} state=${gm._roundState} runners=${runnersAlive}`);
return false;
return false;
});
});
}
}
@@ -10,13 +48,98 @@
@@ -10,13 +48,98 @@
const GM = {
const GM = {
mode: "deathrun",
mode: "deathrun",
name: "OpenVibe Deathrun",
name: "OpenVibe Deathrun",
- Initialize() { OV.log("Deathrun Initialize fired"); registerCommands(); },
- PlayerInitialSpawn(ply) { ply.chat("Deathrun JS loaded. Try !dr_status"); },
+ roundDuration: ROUND_DURATION,
+ countdownDuration: 10,
+
+ Initialize() {
+ OV.log("Deathrun Initialize fired");
+ registerCommands();
+ },
+
+ MapInitialize(mapName) {
+ OV.log(`Deathrun MapInitialize: ${mapName}`);
+ this.scheduleRoundStart();
+ },
+
+ PlayerInitialSpawn(ply) {
+ ply.chat("Deathrun JS loaded. Try !dr_status");
+ },
+
+ startRound() {
+ this._roundNumber += 1;
+ this._roundState = "active";
+ activatorId = null;
+
+ assignTeams();
+
+ OV.log(`[DR] RoundStart round=${this._roundNumber}`);
+ hook.Run("RoundStart", this._roundNumber);
+ OV.broadcast(`Round ${this._roundNumber} — reach the end. Avoid traps. Trust nothing.`);
+
+ const self = this;
+ const roundNum = this._roundNumber;
+ timer.create(`ov_dr_round_end_${roundNum}`, ROUND_DURATION, 1, function () {
+ if (self._roundState === "active" && self._roundNumber === roundNum) {
+ if (runnersAlive > 0) {
+ self.endRound("runners_win");
+ } else {
+ self.endRound("activator_win");
+ }
+ }
+ });
+ },
+
+ endRound(reason) {
+ if (this._roundState !== "active") return;
+ this._roundState = "ended";
+ timer.remove(`ov_dr_round_end_${this._roundNumber}`);
+
+ let msg;
+ if (reason === "runners_win") {
+ msg = "Runners survived! Runners win!";
+ } else if (reason === "activator_win") {
+ msg = "All runners eliminated! Activator wins!";
+ } else {
+ msg = `Round ${this._roundNumber} ended.`;
+ }
+
+ OV.log(`[DR] RoundEnd round=${this._roundNumber} reason=${reason}`);
+ hook.Run("RoundEnd", this._roundNumber, reason);
+ OV.broadcast(msg);
+
+ this.scheduleRoundStart();
+ },
+
+ PlayerDeath(victim, _attacker) {
+ if (!victim || this._roundState !== "active") return;
+ if (victim.team() === TEAM_RUNNERS) {
+ runnersAlive = Math.max(0, runnersAlive - 1);
+ if (runnersAlive === 0) {
+ this.endRound("activator_win");
+ } else {
+ OV.broadcast(`Runner down! ${runnersAlive} runner(s) remaining.`);
+ }
+ }
+ },
+
Think() {}
Think() {}
};
};
+ // Any runner reaching the finish fires DR_RunnerFinished
+ hook.Add("DR_RunnerFinished", "OpenVibeDeathrunFinish", function (ply) {
+ const gm = gamemode.get();
+ if (gm && gm._roundState === "active") {
+ OV.broadcast(`${ply ? ply.name() : "A runner"} reached the end!`);
+ gm.endRound("runners_win");
+ }
+ });
+
hook.Add("PlayerSpawn", "OpenVibeDeathrunSpawnTip", function (ply) {
hook.Add("PlayerSpawn", "OpenVibeDeathrunSpawnTip", function (ply) {
- ply.chat("Reach the end. Avoid traps. Trust nothing.");
+ if (ply.team() === TEAM_RUNNERS) {
+ ply.chat("Reach the end. Avoid traps. Trust nothing.");
+ } else {
+ ply.chat("Activate the traps! Stop the runners!");
+ }
});
});
gamemode.set(GM);
gamemode.set(GM);
@@ -1,15 +1,50 @@
@@ -1,15 +1,50 @@
(function () {
(function () {
+ // Fort Wars round state
+ // Phase 1 — BUILD (team 2 vs team 3 each build separately)
+ // Phase 2 — FIGHT (teams attack each other's fort)
+ // Win condition: last team with living players wins.
+
+ const BUILD_DURATION = 60; // 60 s build phase
+ const FIGHT_DURATION = 180; // 3 min fight phase
+
+ const TEAM_RED = 2;
+ const TEAM_BLUE = 3;
+
+ let phase = "build"; // "build" | "fight"
+ let redAlive = 0;
+ let blueAlive = 0;
+
+ function assignTeams() {
+ const players = OV.players();
+ if (players.length === 0) return;
+ const shuffled = players.slice().sort(() => Math.random() - 0.5);
+ const half = Math.ceil(shuffled.length / 2);
+ shuffled.forEach(function (p, i) {
+ p.setTeam(i < half ? TEAM_RED : TEAM_BLUE);
+ });
+ redAlive = shuffled.filter((p) => p.team() === TEAM_RED).length;
+ blueAlive = shuffled.filter((p) => p.team() === TEAM_BLUE).length;
+ OV.broadcast(`Fort Wars: Red (${redAlive}) vs Blue (${blueAlive})`);
+ }
+
function registerCommands() {
function registerCommands() {
if (!globalThis.command) return;
if (!globalThis.command) return;
+
command.add("fw_status", "Show Fort Wars status", function ({ ply, reply }) {
command.add("fw_status", "Show Fort Wars status", function ({ ply, reply }) {
- reply(ply, `Fort Wars JS online. map=${OV.getMapName()} players=${OV.players().length}`);
+ const gm = gamemode.get();
+ reply(ply, `Fort Wars | round=${gm._roundNumber} phase=${phase} red=${redAlive} blue=${blueAlive}`);
return false;
return false;
});
});
- command.add("build", "Spawn an allowlisted Fort Wars prop", function ({ args, ply, reply }) {
+
+ command.add("build", "Spawn a Fort Wars prop", function ({ args, ply, reply }) {
+ if (phase !== "build") {
+ reply(ply, "Build phase has ended!");
+ return false;
+ }
const choice = args[0] || "crate";
const choice = args[0] || "crate";
if (!ply) return false;
if (!ply) return false;
ply.runCommand(`ov_fortwars_spawn ${choice}`);
ply.runCommand(`ov_fortwars_spawn ${choice}`);
- reply(ply, `Trying Fort Wars prop: ${choice}`);
+ reply(ply, `Placed: ${choice}`);
return false;
return false;
});
});
}
}
@@ -17,13 +52,102 @@
@@ -17,13 +52,102 @@
const GM = {
const GM = {
mode: "fortwars",
mode: "fortwars",
name: "OpenVibe Fort Wars",
name: "OpenVibe Fort Wars",
- Initialize() { OV.log("Fort Wars Initialize fired"); registerCommands(); },
- PlayerInitialSpawn(ply) { ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate"); },
+ roundDuration: BUILD_DURATION + FIGHT_DURATION,
+ countdownDuration: 10,
+
+ Initialize() {
+ OV.log("Fort Wars Initialize fired");
+ registerCommands();
+ },
+
+ MapInitialize(mapName) {
+ OV.log(`Fort Wars MapInitialize: ${mapName}`);
+ this.scheduleRoundStart();
+ },
+
+ PlayerInitialSpawn(ply) {
+ ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate");
+ },
+
+ startRound() {
+ this._roundNumber += 1;
+ this._roundState = "active";
+ phase = "build";
+
+ assignTeams();
+
+ OV.log(`[FW] RoundStart round=${this._roundNumber}`);
+ hook.Run("RoundStart", this._roundNumber);
+ OV.broadcast(`Round ${this._roundNumber} — BUILD PHASE! You have ${BUILD_DURATION}s to fortify!`);
+ OV.serverCommand("ov_fortwars_build_enabled 1");
+
+ const self = this;
+ const roundNum = this._roundNumber;
+
+ // Transition to fight phase
+ timer.simple(BUILD_DURATION, function () {
+ if (self._roundState !== "active" || self._roundNumber !== roundNum) return;
+ phase = "fight";
+ OV.broadcast("BUILD PHASE OVER! FIGHT!");
+ OV.serverCommand("ov_fortwars_build_enabled 0");
+ hook.Run("FW_FightPhaseStart", roundNum);
+ });
+
+ // End of round timer
+ timer.create(`ov_fw_round_end_${roundNum}`, BUILD_DURATION + FIGHT_DURATION, 1, function () {
+ if (self._roundState === "active" && self._roundNumber === roundNum) {
+ if (redAlive > 0 && blueAlive === 0) {
+ self.endRound("red_win");
+ } else if (blueAlive > 0 && redAlive === 0) {
+ self.endRound("blue_win");
+ } else {
+ self.endRound("draw");
+ }
+ }
+ });
+ },
+
+ endRound(reason) {
+ if (this._roundState !== "active") return;
+ this._roundState = "ended";
+ timer.remove(`ov_fw_round_end_${this._roundNumber}`);
+ phase = "build";
+ OV.serverCommand("ov_fortwars_build_enabled 1");
+
+ const msg = reason === "red_win" ? "Red team wins!" :
+ reason === "blue_win" ? "Blue team wins!" :
+ "Draw! Both teams survived.";
+
+ OV.log(`[FW] RoundEnd round=${this._roundNumber} reason=${reason}`);
+ hook.Run("RoundEnd", this._roundNumber, reason);
+ OV.broadcast(msg);
+
+ this.scheduleRoundStart();
+ },
+
+ PlayerDeath(victim, _attacker) {
+ if (!victim || this._roundState !== "active" || phase !== "fight") return;
+ if (victim.team() === TEAM_RED) redAlive = Math.max(0, redAlive - 1);
+ if (victim.team() === TEAM_BLUE) blueAlive = Math.max(0, blueAlive - 1);
+
+ if (redAlive === 0 && blueAlive > 0) {
+ this.endRound("blue_win");
+ } else if (blueAlive === 0 && redAlive > 0) {
+ this.endRound("red_win");
+ } else if (redAlive === 0 && blueAlive === 0) {
+ this.endRound("draw");
+ }
+ },
+
Think() {}
Think() {}
};
};
hook.Add("PlayerSpawn", "OpenVibeFortWarsSpawnTip", function (ply) {
hook.Add("PlayerSpawn", "OpenVibeFortWarsSpawnTip", function (ply) {
- ply.chat("Build phase: use !build crate, !build pallet, or !build fence.");
+ if (phase === "build") {
+ ply.chat(`Build phase: use !build crate, !build pallet, or !build fence. (${BUILD_DURATION}s)`);
+ } else {
+ ply.chat("Fight phase — destroy the enemy fort!");
+ }
});
});
gamemode.set(GM);
gamemode.set(GM);
@@ -1,10 +1,53 @@
@@ -1,10 +1,53 @@
(function () {
(function () {
+ // Prop Hunt round state
+ // Teams: PROPS (team 2) vs HUNTERS (team 3)
+ // Phase 1: props scatter for HUNTER_LOCK_DURATION while hunters are locked.
+ // Phase 2: hunters are released, full round timer runs.
+ // Win conditions:
+ // - All props eliminated → hunters win
+ // - Timer runs out with ≥1 prop alive → props win
+
+ const HUNTER_LOCK_DURATION = 20; // seconds hunters wait at round start
+ const ROUND_DURATION = 180; // total round time in seconds
+
+ const TEAM_PROPS = 2;
+ const TEAM_HUNTERS = 3;
+
+ let propsAlive = 0;
+ let huntersAlive = 0;
+ let locked = false;
+
+ function countAlive() {
+ const players = OV.players();
+ propsAlive = players.filter((p) => p.team() === TEAM_PROPS).length;
+ huntersAlive = players.filter((p) => p.team() === TEAM_HUNTERS).length;
+ }
+
+ function assignTeams() {
+ const players = OV.players();
+ if (players.length === 0) return;
+
+ // 1 hunter per 3 props, minimum 1 hunter
+ const hunterCount = Math.max(1, Math.floor(players.length / 4));
+ const shuffled = players.slice().sort(() => Math.random() - 0.5);
+
+ shuffled.forEach(function (p, i) {
+ p.setTeam(i < hunterCount ? TEAM_HUNTERS : TEAM_PROPS);
+ });
+
+ countAlive();
+ OV.broadcast(`Teams set: ${propsAlive} props vs ${huntersAlive} hunters.`);
+ }
+
function registerCommands() {
function registerCommands() {
if (!globalThis.command) return;
if (!globalThis.command) return;
+
command.add("ph_status", "Show Prop Hunt status", function ({ ply, reply }) {
command.add("ph_status", "Show Prop Hunt status", function ({ ply, reply }) {
- reply(ply, `Prop Hunt JS online. map=${OV.getMapName()} players=${OV.players().length}`);
+ const gm = gamemode.get();
+ reply(ply, `Prop Hunt | round=${gm._roundNumber} state=${gm._roundState} props=${propsAlive} hunters=${huntersAlive} locked=${locked}`);
return false;
return false;
});
});
+
command.add("disguise", "Disguise as an allowlisted prop", function ({ args, ply, reply }) {
command.add("disguise", "Disguise as an allowlisted prop", function ({ args, ply, reply }) {
const choice = args[0] || "crate";
const choice = args[0] || "crate";
if (!ply) return false;
if (!ply) return false;
@@ -17,12 +60,104 @@
@@ -17,12 +60,104 @@
const GM = {
const GM = {
mode: "prophunt",
mode: "prophunt",
name: "OpenVibe Prop Hunt",
name: "OpenVibe Prop Hunt",
- Initialize() { OV.log("Prop Hunt Initialize fired"); registerCommands(); },
- PlayerInitialSpawn(ply) { ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate"); },
+ roundDuration: ROUND_DURATION,
+ countdownDuration: 10,
+
+ Initialize() {
+ OV.log("Prop Hunt Initialize fired");
+ registerCommands();
+ },
+
+ MapInitialize(mapName) {
+ OV.log(`Prop Hunt MapInitialize: ${mapName}`);
+ this.scheduleRoundStart();
+ },
+
+ PlayerInitialSpawn(ply) {
+ ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate");
+ },
+
+ startRound() {
+ this._roundNumber += 1;
+ this._roundState = "active";
+ locked = true;
+
+ assignTeams();
+
+ OV.log(`[PH] RoundStart round=${this._roundNumber}`);
+ hook.Run("RoundStart", this._roundNumber);
+ OV.broadcast(`Round ${this._roundNumber} — props, hide now! Hunters release in ${HUNTER_LOCK_DURATION}s.`);
+
+ // Lock hunters, release after delay
+ const self = this;
+ const roundNum = this._roundNumber;
+ timer.simple(HUNTER_LOCK_DURATION, function () {
+ if (self._roundState !== "active" || self._roundNumber !== roundNum) return;
+ locked = false;
+ OV.broadcast("Hunters released! Find the props!");
+ });
+
+ // Full round timer
+ timer.create(`ov_ph_round_end_${roundNum}`, ROUND_DURATION, 1, function () {
+ if (self._roundState === "active" && self._roundNumber === roundNum) {
+ if (propsAlive > 0) {
+ self.endRound("props_win");
+ } else {
+ self.endRound("hunters_win");
+ }
+ }
+ });
+ },
+
+ endRound(reason) {
+ if (this._roundState !== "active") return;
+ this._roundState = "ended";
+ locked = false;
+ timer.remove(`ov_ph_round_end_${this._roundNumber}`);
+
+ let msg;
+ if (reason === "props_win") {
+ msg = "Props survived! Props win!";
+ } else if (reason === "hunters_win") {
+ msg = "All props found! Hunters win!";
+ } else {
+ msg = `Round ${this._roundNumber} ended.`;
+ }
+
+ OV.log(`[PH] RoundEnd round=${this._roundNumber} reason=${reason}`);
+ hook.Run("RoundEnd", this._roundNumber, reason);
+ OV.broadcast(msg);
+
+ this.scheduleRoundStart();
+ },
+
+ PlayerDeath(victim, _attacker) {
+ if (!victim) return;
+ countAlive();
+ if (this._roundState !== "active") return;
+
+ if (victim.team() === TEAM_PROPS) {
+ if (propsAlive === 0) {
+ this.endRound("hunters_win");
+ } else {
+ OV.broadcast(`A prop was found! ${propsAlive} prop(s) remaining.`);
+ }
+ }
+ },
+
Think() {}
Think() {}
};
};
+
hook.Add("PlayerSpawn", "OpenVibePropHuntSpawnTip", function (ply) {
hook.Add("PlayerSpawn", "OpenVibePropHuntSpawnTip", function (ply) {
- ply.chat("Blend in. Use !disguise crate, !disguise barrel, or !disguise chair.");
+ if (locked && ply.team() === TEAM_HUNTERS) {
+ ply.chat("Wait — hunters are locked until props hide.");
+ return;
+ }
+ if (ply.team() === TEAM_PROPS) {
+ ply.chat("Hide! Use !disguise crate, !disguise barrel, or !disguise chair.");
+ } else {
+ ply.chat("Hunt the props down!");
+ }
});
});
gamemode.set(GM);
gamemode.set(GM);
@@ -1,8 +1,61 @@
@@ -1,8 +1,61 @@
(function () {
(function () {
+ // Traitor Town round state
+ // Roles: INNOCENT (team 2) vs TRAITOR (team 3)
+ // ~1 traitor per 4 players, minimum 1.
+ // Win conditions:
+ // - All traitors dead → innocents win
+ // - All innocents dead → traitors win
+ // - Timer runs out with traitors alive → traitors win
+
+ const ROUND_DURATION = 300; // 5 minutes
+
+ const TEAM_INNOCENT = 2;
+ const TEAM_TRAITOR = 3;
+
+ let innocentsAlive = 0;
+ let traitorsAlive = 0;
+ let rolesAssigned = false;
+
+ function assignRoles() {
+ const players = OV.players();
+ if (players.length < 2) return;
+
+ rolesAssigned = false;
+ const shuffled = players.slice().sort(() => Math.random() - 0.5);
+ const traitorCount = Math.max(1, Math.floor(shuffled.length / 4));
+
+ shuffled.forEach(function (p, i) {
+ p.setTeam(i < traitorCount ? TEAM_TRAITOR : TEAM_INNOCENT);
+ });
+
+ innocentsAlive = shuffled.filter((p) => p.team() === TEAM_INNOCENT).length;
+ traitorsAlive = shuffled.filter((p) => p.team() === TEAM_TRAITOR).length;
+ rolesAssigned = true;
+
+ // Tell traitors who they are (privately via chat)
+ shuffled.filter((p) => p.team() === TEAM_TRAITOR).forEach(function (p) {
+ p.chat("[TRAITOR] You are a traitor. Eliminate all innocents.");
+ });
+
+ OV.broadcast(`Round ${gamemode.get()._roundNumber} — ${players.length} players. Find the traitor(s)!`);
+ }
+
function registerCommands() {
function registerCommands() {
if (!globalThis.command) return;
if (!globalThis.command) return;
+
command.add("ttt_status", "Show Traitor Town status", function ({ ply, reply }) {
command.add("ttt_status", "Show Traitor Town status", function ({ ply, reply }) {
- reply(ply, `Traitor Town JS online. map=${OV.getMapName()} players=${OV.players().length}`);
+ const gm = gamemode.get();
+ reply(ply, `Traitor Town | round=${gm._roundNumber} state=${gm._roundState} innocents=${innocentsAlive} traitors=${traitorsAlive}`);
+ return false;
+ });
+
+ command.add("role", "Show your current role", function ({ ply, reply }) {
+ if (!ply || !rolesAssigned) {
+ reply(ply, "Roles have not been assigned yet.");
+ return false;
+ }
+ const role = ply.team() === TEAM_TRAITOR ? "TRAITOR" : "INNOCENT";
+ reply(ply, `Your role: ${role}`);
return false;
return false;
});
});
}
}
@@ -10,13 +63,86 @@
@@ -10,13 +63,86 @@
const GM = {
const GM = {
mode: "traitortown",
mode: "traitortown",
name: "OpenVibe Traitor Town",
name: "OpenVibe Traitor Town",
- Initialize() { OV.log("Traitor Town Initialize fired"); registerCommands(); },
- PlayerInitialSpawn(ply) { ply.chat("Traitor Town JS loaded. Try !ttt_status"); },
+ roundDuration: ROUND_DURATION,
+ countdownDuration: 15,
+
+ Initialize() {
+ OV.log("Traitor Town Initialize fired");
+ registerCommands();
+ },
+
+ MapInitialize(mapName) {
+ OV.log(`Traitor Town MapInitialize: ${mapName}`);
+ this.scheduleRoundStart();
+ },
+
+ PlayerInitialSpawn(ply) {
+ ply.chat("Traitor Town JS loaded. Try !ttt_status or !role");
+ },
+
+ startRound() {
+ this._roundNumber += 1;
+ this._roundState = "active";
+ rolesAssigned = false;
+ innocentsAlive = 0;
+ traitorsAlive = 0;
+
+ assignRoles();
+
+ OV.log(`[TTT] RoundStart round=${this._roundNumber} traitors=${traitorsAlive} innocents=${innocentsAlive}`);
+ hook.Run("RoundStart", this._roundNumber);
+
+ const self = this;
+ const roundNum = this._roundNumber;
+ timer.create(`ov_ttt_round_end_${roundNum}`, ROUND_DURATION, 1, function () {
+ if (self._roundState === "active" && self._roundNumber === roundNum) {
+ // Traitors win if still alive at time
+ self.endRound(traitorsAlive > 0 ? "traitors_win" : "innocents_win");
+ }
+ });
+ },
+
+ endRound(reason) {
+ if (this._roundState !== "active") return;
+ this._roundState = "ended";
+ rolesAssigned = false;
+ timer.remove(`ov_ttt_round_end_${this._roundNumber}`);
+
+ const msg = reason === "traitors_win" ? "Traitors win! The innocents never suspected a thing." :
+ reason === "innocents_win" ? "Innocents win! All traitors have been identified." :
+ `Round ${this._roundNumber} ended.`;
+
+ OV.log(`[TTT] RoundEnd round=${this._roundNumber} reason=${reason}`);
+ hook.Run("RoundEnd", this._roundNumber, reason);
+ OV.broadcast(msg);
+
+ this.scheduleRoundStart();
+ },
+
+ PlayerDeath(victim, _attacker) {
+ if (!victim || this._roundState !== "active" || !rolesAssigned) return;
+
+ if (victim.team() === TEAM_INNOCENT) {
+ innocentsAlive = Math.max(0, innocentsAlive - 1);
+ OV.broadcast(`An innocent has died. ${innocentsAlive} innocent(s) remaining.`);
+ if (innocentsAlive === 0) this.endRound("traitors_win");
+ } else if (victim.team() === TEAM_TRAITOR) {
+ traitorsAlive = Math.max(0, traitorsAlive - 1);
+ OV.broadcast(`A traitor has been found! ${traitorsAlive} traitor(s) remaining.`);
+ if (traitorsAlive === 0) this.endRound("innocents_win");
+ }
+ },
+
Think() {}
Think() {}
};
};
hook.Add("PlayerSpawn", "OpenVibeTraitorTownSpawnTip", function (ply) {
hook.Add("PlayerSpawn", "OpenVibeTraitorTownSpawnTip", function (ply) {
- ply.chat("Watch the room. Someone here is lying.");
+ if (rolesAssigned) {
+ const role = ply.team() === TEAM_TRAITOR ? "TRAITOR" : "innocent";
+ ply.chat(`Your role: ${role.toUpperCase()}. Type !role to check.`);
+ } else {
+ ply.chat("Watch the room. Someone here is lying. Type !role once the round starts.");
+ }
});
});
gamemode.set(GM);
gamemode.set(GM);
@@ -80,6 +80,44 @@ static ConCommand ov_js_cmd(
@@ -80,6 +80,44 @@ static ConCommand ov_js_cmd(
"Send a ConsoleCommand event into the embedded OpenVibe JavaScript runtime.",
"Send a ConsoleCommand event into the embedded OpenVibe JavaScript runtime.",
FCVAR_GAMEDLL
FCVAR_GAMEDLL
);
);
+
+void OpenVibeJS_Server_RoundStart(int roundNumber)
+{
+ Msg("[OV JS] Windows server CI stub: RoundStart round=%d\n", roundNumber);
+}
+
+void OpenVibeJS_Server_RoundEnd(int roundNumber, const char *reason)
+{
+ Msg("[OV JS] Windows server CI stub: RoundEnd round=%d reason=%s\n",
+ roundNumber, reason ? reason : "");
+}
+
+static void OV_RoundStart_f(const CCommand &args)
+{
+ const int round = args.ArgC() >= 2 ? Q_atoi(args[1]) : 1;
+ OpenVibeJS_Server_RoundStart(round);
+}
+
+static ConCommand ov_round_start(
+ "ov_round_start",
+ OV_RoundStart_f,
+ "Fire the RoundStart hook into the OpenVibe JavaScript runtime.",
+ FCVAR_GAMEDLL
+);
+
+static void OV_RoundEnd_f(const CCommand &args)
+{
+ const int round = args.ArgC() >= 2 ? Q_atoi(args[1]) : 1;
+ const char *pszReason = args.ArgC() >= 3 ? args[2] : "time";
+ OpenVibeJS_Server_RoundEnd(round, pszReason);
+}
+
+static ConCommand ov_round_end(
+ "ov_round_end",
+ OV_RoundEnd_f,
+ "Fire the RoundEnd hook into the OpenVibe JavaScript runtime. Usage: ov_round_end <round> [reason]",
+ FCVAR_GAMEDLL
+);
#else
#else
#include "hl2mp_player.h"
#include "hl2mp_player.h"
#include "openvibe_js_server.h"
#include "openvibe_js_server.h"
@@ -347,4 +385,65 @@ static ConCommand ov_js_cmd(
@@ -347,4 +385,65 @@ static ConCommand ov_js_cmd(
"Send a ConsoleCommand event into the embedded OpenVibe JavaScript runtime.",
"Send a ConsoleCommand event into the embedded OpenVibe JavaScript runtime.",
FCVAR_GAMEDLL
FCVAR_GAMEDLL
);
);
+
+void OpenVibeJS_Server_RoundStart( int roundNumber )
+{
+ OpenVibeJS_EnsureStarted();
+
+ if ( !OpenVibeJS_IsRunning() )
+ return;
+
+ JSContext *ctx = g_OVServerJS.Context();
+ JSValue jsRound = JS_NewInt32( ctx, roundNumber );
+ JSValueConst argv[] = { jsRound };
+ g_OVServerJS.CallHookVoid( "RoundStart", 1, argv );
+ JS_FreeValue( ctx, jsRound );
+
+ Msg( "[OV JS] RoundStart fired round=%d\n", roundNumber );
+}
+
+void OpenVibeJS_Server_RoundEnd( int roundNumber, const char *reason )
+{
+ OpenVibeJS_EnsureStarted();
+
+ if ( !OpenVibeJS_IsRunning() )
+ return;
+
+ JSContext *ctx = g_OVServerJS.Context();
+ JSValue jsRound = JS_NewInt32( ctx, roundNumber );
+ JSValue jsReason = JS_NewString( ctx, reason ? reason : "time" );
+ JSValueConst argv[] = { jsRound, jsReason };
+ g_OVServerJS.CallHookVoid( "RoundEnd", 2, argv );
+ JS_FreeValue( ctx, jsRound );
+ JS_FreeValue( ctx, jsReason );
+
+ Msg( "[OV JS] RoundEnd fired round=%d reason=%s\n", roundNumber, reason ? reason : "time" );
+}
+
+static void OV_RoundStart_f( const CCommand &args )
+{
+ const int round = args.ArgC() >= 2 ? Q_atoi( args[1] ) : 1;
+ OpenVibeJS_Server_RoundStart( round );
+}
+
+static ConCommand ov_round_start(
+ "ov_round_start",
+ OV_RoundStart_f,
+ "Fire the RoundStart hook into the OpenVibe JavaScript runtime.",
+ FCVAR_GAMEDLL
+);
+
+static void OV_RoundEnd_f( const CCommand &args )
+{
+ const int round = args.ArgC() >= 2 ? Q_atoi( args[1] ) : 1;
+ const char *pszReason = args.ArgC() >= 3 ? args[2] : "time";
+ OpenVibeJS_Server_RoundEnd( round, pszReason );
+}
+
+static ConCommand ov_round_end(
+ "ov_round_end",
+ OV_RoundEnd_f,
+ "Fire the RoundEnd hook into the OpenVibe JavaScript runtime. Usage: ov_round_end <round> [reason]",
+ FCVAR_GAMEDLL
+);
#endif
#endif
@@ -14,5 +14,8 @@ void OpenVibeJS_Server_PlayerDeath(CHL2MP_Player *victim, CBaseEntity *attacker,
@@ -14,5 +14,8 @@ void OpenVibeJS_Server_PlayerDeath(CHL2MP_Player *victim, CBaseEntity *attacker,
void OpenVibeJS_Server_PlayerDisconnected(CHL2MP_Player *player);
void OpenVibeJS_Server_PlayerDisconnected(CHL2MP_Player *player);
bool OpenVibeJS_Server_PlayerSay(CHL2MP_Player *player, const char *text);
bool OpenVibeJS_Server_PlayerSay(CHL2MP_Player *player, const char *text);
+void OpenVibeJS_Server_RoundStart(int roundNumber);
+void OpenVibeJS_Server_RoundEnd(int roundNumber, const char *reason);
+
void OpenVibeJS_Server_ConsoleCommand(const char *text);
void OpenVibeJS_Server_ConsoleCommand(const char *text);
void OpenVibeJS_Server_FireHook(const char *hookName);
void OpenVibeJS_Server_FireHook(const char *hookName);