openvibers/source.openvibe.games Commits

Round system, batch rewards, and C++ RoundStart/RoundEnd bridge

CO copilot-swe-agent[bot] committed 30 Jun 2026 · 18c267fb48 · 16 files, +906 −23


Changes

ddiff --git a/DEPLOYMENT_STATUS.md b/DEPLOYMENT_STATUS.md
iindex e825e67..30eef3c 100644
---- a/DEPLOYMENT_STATUS.md
++++ b/DEPLOYMENT_STATUS.md
@@@ -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. |
ddiff --git a/PROJECT_INDEX.md b/PROJECT_INDEX.md
iindex 7aeedea..4954b26 100644
---- a/PROJECT_INDEX.md
++++ b/PROJECT_INDEX.md
@@@ -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.mjs
tools/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.
ddiff --git a/backend/src/app.test.ts b/backend/src/app.test.ts
iindex 1833866..a920cbb 100644
---- a/backend/src/app.test.ts
++++ b/backend/src/app.test.ts
@@@ -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();
++ });
});
ddiff --git a/backend/src/app.ts b/backend/src/app.ts
iindex 4cf3afc..6bd83b3 100644
---- a/backend/src/app.ts
++++ b/backend/src/app.ts
@@@ -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=prophunt
app.get("/v1/leaderboard", async (request) => {
const query = leaderboardQuerySchema.parse(request.query);
ddiff --git a/backend/src/domain.ts b/backend/src/domain.ts
iindex 324bd20..5fd17a6 100644
---- a/backend/src/domain.ts
++++ b/backend/src/domain.ts
@@@ -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: {
ddiff --git a/backend/src/repository-memory.ts b/backend/src/repository-memory.ts
iindex 2ab3549..20d163d 100644
---- a/backend/src/repository-memory.ts
++++ b/backend/src/repository-memory.ts
@@@ -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);
ddiff --git a/backend/src/repository-pg.ts b/backend/src/repository-pg.ts
iindex a748e2b..bd6f69e 100644
---- a/backend/src/repository-pg.ts
++++ b/backend/src/repository-pg.ts
@@@ -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(
`
ddiff --git a/backend/src/schemas.ts b/backend/src/schemas.ts
iindex 710cf3a..3b6dcd8 100644
---- a/backend/src/schemas.ts
++++ b/backend/src/schemas.ts
@@@ -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(),
ddiff --git a/docs/OPENVIBE_JS_SCRIPTING.md b/docs/OPENVIBE_JS_SCRIPTING.md
iindex dc584ec..aed78d8 100644
---- a/docs/OPENVIBE_JS_SCRIPTING.md
++++ b/docs/OPENVIBE_JS_SCRIPTING.md
@@@ -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)
```
ddiff --git a/game/openvibe.games/js/gamemodes/base/server.js b/game/openvibe.games/js/gamemodes/base/server.js
iindex 12294f9..f067b99 100644
---- a/game/openvibe.games/js/gamemodes/base/server.js
++++ b/game/openvibe.games/js/gamemodes/base/server.js
@@@ -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() {}
};
ddiff --git a/game/openvibe.games/js/gamemodes/deathrun/server.js b/game/openvibe.games/js/gamemodes/deathrun/server.js
iindex d7676fa..964d5eb 100644
---- a/game/openvibe.games/js/gamemodes/deathrun/server.js
++++ b/game/openvibe.games/js/gamemodes/deathrun/server.js
@@@ -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);
ddiff --git a/game/openvibe.games/js/gamemodes/fortwars/server.js b/game/openvibe.games/js/gamemodes/fortwars/server.js
iindex 6bd1fa9..4eb1a55 100644
---- a/game/openvibe.games/js/gamemodes/fortwars/server.js
++++ b/game/openvibe.games/js/gamemodes/fortwars/server.js
@@@ -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);
ddiff --git a/game/openvibe.games/js/gamemodes/prophunt/server.js b/game/openvibe.games/js/gamemodes/prophunt/server.js
iindex 24243ed..636541d 100644
---- a/game/openvibe.games/js/gamemodes/prophunt/server.js
++++ b/game/openvibe.games/js/gamemodes/prophunt/server.js
@@@ -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);
ddiff --git a/game/openvibe.games/js/gamemodes/traitortown/server.js b/game/openvibe.games/js/gamemodes/traitortown/server.js
iindex 4dc14be..c427249 100644
---- a/game/openvibe.games/js/gamemodes/traitortown/server.js
++++ b/game/openvibe.games/js/gamemodes/traitortown/server.js
@@@ -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);
ddiff --git a/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp b/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp
iindex ac62d93..4f72967 100644
---- a/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp
++++ b/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp
@@@ -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
ddiff --git a/sdk/openvibe/server/hl2mp/openvibe_js_server.h b/sdk/openvibe/server/hl2mp/openvibe_js_server.h
iindex 744a7ad..5ab3162 100644
---- a/sdk/openvibe/server/hl2mp/openvibe_js_server.h
++++ b/sdk/openvibe/server/hl2mp/openvibe_js_server.h
@@@ -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);