Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ The rest of the details are in the script, it's simple, and the script installat
- `!pause`
- `!rally`
- `!rally pause`
- For checking time to next rally: `!rc` (or `!rcheck`, `!rallycheck`)
- Rally spawn cycle length is auto-detected: 60 seconds on vanilla layers, 45 seconds on SuperMod layers (raw layer id starts with `SU_`). You can override this with the `rally_interval_seconds` option.

## Configuration

Expand Down Expand Up @@ -59,6 +61,11 @@ The rest of the details are in the script, it's simple, and the script installat
"description": "List of dedicated commands to accept a squad rally invitation",
"default": ["rtyes"]
},
"commands_to_check": {
"required": false,
"description": "List of commands to check the time remaining until the next rally spawn",
"default": ["rc", "rcheck", "rallycheck"]
},
"time_before_spawn": {
"required": false,
"description": "Default time before spawn at rally point",
Expand All @@ -68,6 +75,11 @@ The rest of the details are in the script, it's simple, and the script installat
"required": false,
"description": "Maximum timer time in minutes",
"default": 120
},
"rally_interval_seconds": {
"required": false,
"description": "Rally spawn cycle length in seconds. Leave unset to auto-detect (60s vanilla, 45s on SuperMod layers prefixed with SU_).",
"default": null
}
},
{
Expand Down
177 changes: 137 additions & 40 deletions rally-timer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,22 @@ export default class RallyTimer extends BasePlugin {
required: false,
description: "List of commands to start the rally timer (the first entry is used in the reminder message as a note!)",
default: ["sr", "stop", "rs", "rts"],
}, commands_to_pause: {
required: false,
description: "List of commands to pause the rally timer (the first entry is used in the reminder message as a note!)",
default: ["pr", "pause", "rp", "rtp"],
}, time_before_spawn: {
required: false, description: "Default time before spawn at rally point", default: 20,
}, commands_to_accept_squad: {
required: false,
description: "List of dedicated commands to accept a squad rally invitation",
default: ["rtyes"],
default: ["yes", "y", "yesrt", "rtyes", "accept"],
}, commands_to_check: {
required: false,
description: "List of commands to check the time remaining until the next rally spawn",
default: ["rc", "rtcheck", "rcheck", "rallycheck"],
}, max_time: {
required: false, description: "Maximum timer time in minutes", default: 120
}, rally_interval_seconds: {
required: false,
description: "Rally spawn cycle length in seconds. Leave unset to auto-detect (60s vanilla, 45s on SuperMod layers prefixed with SU_).",
default: null,
},
};
}
Expand All @@ -42,6 +46,7 @@ export default class RallyTimer extends BasePlugin {
this.rallyTimerPaused = new Map();
this.optedOutPlayers = new Set();
this.pendingSquadInvites = new Map();
this.nextSpawnAt = new Map();

this.warn = this.warn.bind(this);
this.startIntervalMessages = this.startIntervalMessages.bind(this);
Expand All @@ -52,6 +57,13 @@ export default class RallyTimer extends BasePlugin {
this.handleSquadRally = this.handleSquadRally.bind(this);
this.handleAcceptInvite = this.handleAcceptInvite.bind(this);
this.handleOptOut = this.handleOptOut.bind(this);
this.handleCheckTime = this.handleCheckTime.bind(this);
this.getRallyIntervalSeconds = this.getRallyIntervalSeconds.bind(this);
this.onPlayerWounded = this.onPlayerWounded.bind(this);
this.onPlayerAlive = this.onPlayerAlive.bind(this);
this.onPlayerRevived = this.onPlayerRevived.bind(this);
this.onPlayerDied = this.onPlayerDied.bind(this);
this.onPlayerDamaged = this.onPlayerDamaged.bind(this);
}

async mount() {
Expand All @@ -69,18 +81,24 @@ export default class RallyTimer extends BasePlugin {
});
}

for (const command of this.options.commands_to_pause) {
for (const command of this.options.commands_to_accept_squad) {
this.server.on(`CHAT_COMMAND:${command}`, (data) => {
this.togglePauseIntervalMessages(data.player.steamID);
this.handleAcceptInvite(data.player);
});
}

for (const command of this.options.commands_to_accept_squad) {
for (const command of this.options.commands_to_check) {
this.server.on(`CHAT_COMMAND:${command}`, (data) => {
this.handleAcceptInvite(data.player);
this.handleCheckTime(data.player);
});
}

this.server.on("PLAYER_WOUNDED", (data) => this.onPlayerWounded(data));
this.server.on("TEAMKILL", (data) => this.onPlayerWounded(data));
this.server.on("PLAYER_DIED", (data) => this.onPlayerDied(data));
this.server.on("PLAYER_REVIVED", (data) => this.onPlayerRevived(data));
this.server.on("PLAYER_DAMAGED", (data) => this.onPlayerDamaged(data));

this.server.on("ROUND_ENDED", () => {
this.clearAllTimeouts();
});
Expand Down Expand Up @@ -128,24 +146,25 @@ export default class RallyTimer extends BasePlugin {
timeBeforeSpawn = customTimeBeforeSpawn;
}

const firstMessageDelay = rallyTime > timeBeforeSpawn ? (rallyTime - timeBeforeSpawn) * 1000 : (60 - timeBeforeSpawn + rallyTime) * 1000;
const cycleSeconds = this.getRallyIntervalSeconds();
const firstMessageDelay = rallyTime > timeBeforeSpawn ? (rallyTime - timeBeforeSpawn) * 1000 : (cycleSeconds - timeBeforeSpawn + rallyTime) * 1000;

this.activateIntervalMessagesAboutRally(firstMessageDelay, data.player, timeBeforeSpawn);

isTimerSet = true;
}
}

// Toggle timer, if command used without time and timer is already set
if (!isTimerSet && this.playerTimer.has(data.player.steamID)) {
this.togglePauseIntervalMessages(data.player.steamID);
// Accept pending squad invite if command used without arguments
if (!isTimerSet && this.pendingSquadInvites.has(data.player.steamID)) {
this.handleAcceptInvite(data.player);
return;
}

if (!isTimerSet) {
this.warn(data.player.steamID, `Enter the CURRENT rally time (from 0 to ${this.options.max_time})\n\nFor example:\nTimer shows 30 seconds, then: !rally 30\nSquad rally: !rally 30 sq`);
await new Promise((resolve) => setTimeout(resolve, 6 * 1000));
this.warn(data.player.steamID, `Custom reminder time. For example:\n!rally 30 25\nThis will set a reminder 25 seconds before spawn.`);
this.warn(data.player.steamID, `Custom reminder time. For example:\n!rally 30 25\nThis will set a reminder 25 seconds before spawn.\nWarnings appear automatically when you're wounded.`);
}
}
}
Expand All @@ -155,22 +174,50 @@ export default class RallyTimer extends BasePlugin {
clearTimeout(this.playerTimer.get(steamID));
this.playerTimer.delete(steamID);
this.rallyTimerPaused.delete(steamID);
this.nextSpawnAt.delete(steamID);
this.warn(steamID, "Stopped sending rally reminders");
}
}

togglePauseIntervalMessages(steamID) {
// Resume from pause, if player has an active timer and paused the timer before
if (this.playerTimer.has(steamID) && this.rallyTimerPaused.has(steamID)) {
this.rallyTimerPaused.delete(steamID);
this.warn(steamID, `Rally reminder RESUMED.`);
onPlayerWounded(data) {
const steamID = data.victim?.steamID;
if (!steamID || !this.playerTimer.has(steamID)) return;
if (!this.rallyTimerPaused.has(steamID)) return; // already wounded/unpaused

const pauseData = this.rallyTimerPaused.get(steamID);
this.rallyTimerPaused.delete(steamID);

if (pauseData.lastTickAt) {
const timeSinceLastTick = (Date.now() - pauseData.lastTickAt) / 1000;
let timeUntilSpawn = Math.round(pauseData.timeBeforeSpawn - timeSinceLastTick);
if (timeUntilSpawn < 0) timeUntilSpawn += this.getRallyIntervalSeconds();
this.warn(steamID, `Rally spawn in ~${timeUntilSpawn} seconds!`);
}
// Pause the timer, if player has an active timer and did not pause the timer before
else if (this.playerTimer.has(steamID) && !this.rallyTimerPaused.has(steamID)) {
this.rallyTimerPaused.set(steamID, true);
this.warn(steamID, `Rally reminder PAUSED!\nTo resume, just use the command again.`);
} else {
this.warn(steamID, `You don't have an active rally reminder to pause or resume.`);
}

onPlayerAlive(steamID) {
if (!steamID || !this.playerTimer.has(steamID)) return;
if (this.rallyTimerPaused.has(steamID)) return; // already alive/paused

this.rallyTimerPaused.set(steamID, {});
}

onPlayerRevived(data) {
this.onPlayerAlive(data.victim?.steamID);
this.onPlayerAlive(data.reviver?.steamID);
}

onPlayerDied(data) {
this.onPlayerAlive(data.victim?.steamID);
}

onPlayerDamaged(data) {
if (data.attackerSteamID) {
this.onPlayerAlive(data.attackerSteamID);
}
const victim = this.server.players.find(p => p.name === data.victimName);
if (victim) {
this.onPlayerAlive(victim.steamID);
}
}

Expand All @@ -181,6 +228,7 @@ export default class RallyTimer extends BasePlugin {
this.playerTimer.clear();
this.rallyTimerPaused.clear();
this.pendingSquadInvites.clear();
this.nextSpawnAt.clear();
}

handleSquadRally(initiator, rallyTime) {
Expand All @@ -194,11 +242,12 @@ export default class RallyTimer extends BasePlugin {
this.rallyTimerPaused.delete(initiator.steamID);

const timeBeforeSpawn = this.options.time_before_spawn;
const firstMessageDelay = rallyTime > timeBeforeSpawn ? (rallyTime - timeBeforeSpawn) * 1000 : (60 - timeBeforeSpawn + rallyTime) * 1000;
const cycleSeconds = this.getRallyIntervalSeconds();
const firstMessageDelay = rallyTime > timeBeforeSpawn ? (rallyTime - timeBeforeSpawn) * 1000 : (cycleSeconds - timeBeforeSpawn + rallyTime) * 1000;

this.activateIntervalMessagesAboutRally(firstMessageDelay, initiator, timeBeforeSpawn);

// Anchor for syncing squad members to the same 60s reminder cycle
// Anchor for syncing squad members to the same reminder cycle
const cycleAnchor = Date.now() + firstMessageDelay;

// Find squad members (excluding initiator)
Expand Down Expand Up @@ -247,17 +296,17 @@ export default class RallyTimer extends BasePlugin {
clearTimeout(this.playerTimer.get(player.steamID));
this.rallyTimerPaused.delete(player.steamID);

// Sync to the initiator's 60s reminder cycle
// Sync to the initiator's reminder cycle
const now = Date.now();
const elapsed = now - invite.cycleAnchor;
const cycleMs = 60 * 1000;
const cycleMs = this.getRallyIntervalSeconds() * 1000;
let syncedDelay;

if (elapsed < 0) {
// First reminder hasn't fired yet
syncedDelay = -elapsed;
} else {
// Align to the next tick in the 60s cycle
// Align to the next tick in the reminder cycle
syncedDelay = cycleMs - (elapsed % cycleMs);
}

Expand All @@ -273,34 +322,45 @@ export default class RallyTimer extends BasePlugin {
}

activateIntervalMessagesAboutRally(delay, player, timeBeforeSpawn) {
let commandPausePrefix = this.getCommandPausePrefixString();
let commandStopPrefix = this.getCommandStopPrefixString();

this.warn(
player.steamID,
`Get a reminder ${timeBeforeSpawn} seconds before spawn at the rally.
\nPAUSE with: ${commandPausePrefix}
\nSTOP with: ${commandStopPrefix}`
`Rally reminder active (${timeBeforeSpawn}s before spawn).` +
`\nWarnings appear when you're wounded.` +
`\nSTOP with: ${commandStopPrefix}`
);

// Start in alive/suppressed state — messages only sent when wounded
this.rallyTimerPaused.set(player.steamID, {});

this.nextSpawnAt.set(player.steamID, Date.now() + delay + timeBeforeSpawn * 1000);

this.playerTimer.set(player.steamID, setTimeout(() => {
this.sendMessageAboutRally(player.steamID, timeBeforeSpawn);

const intervalId = setInterval(() => this.sendMessageAboutRally(player.steamID, timeBeforeSpawn), 60 * 1000);
const intervalId = setInterval(() => this.sendMessageAboutRally(player.steamID, timeBeforeSpawn), this.getRallyIntervalSeconds() * 1000);

this.playerTimer.set(player.steamID, intervalId);
}, delay));
}

async sendMessageAboutRally(steamID, timeBeforeSpawn) {
// Do not send message if paused
if (this.rallyTimerPaused.get(steamID)) {
// Update next spawn timestamp on every tick, regardless of pause state,
// so that the check command stays accurate.
this.nextSpawnAt.set(steamID, Date.now() + timeBeforeSpawn * 1000);

// Do not send message if alive (paused), but track timing so wound event can show time until spawn
const pauseData = this.rallyTimerPaused.get(steamID);
if (pauseData) {
pauseData.timeBeforeSpawn = timeBeforeSpawn;
pauseData.lastTickAt = Date.now();
return;
}

await this.warn(
steamID,
`Rally spawn in ${timeBeforeSpawn} seconds! (!` + this.options.commands_to_stop[0] + ` or !` + this.options.commands_to_pause[0] + `)`
`Rally spawn in ${timeBeforeSpawn} seconds! (!` + this.options.commands_to_stop[0] + ` to stop)`
);
}

Expand All @@ -319,7 +379,44 @@ export default class RallyTimer extends BasePlugin {
return '!' + this.options.commands_to_stop.join(', !');
}

getCommandPausePrefixString() {
return '!' + this.options.commands_to_pause.join(', !');
handleCheckTime(player) {
if (!player) return;

const steamID = player.steamID;
if (!this.playerTimer.has(steamID) || !this.nextSpawnAt.has(steamID)) {
this.warn(steamID, "You don't have an active rally timer. Start one with: !rally <seconds>");
return;
}

const nextSpawnAt = this.nextSpawnAt.get(steamID);
let secondsLeft = Math.round((nextSpawnAt - Date.now()) / 1000);

// Gracefully handle a just-passed spawn by advancing in cycle-length steps.
const cycleSeconds = this.getRallyIntervalSeconds();
while (secondsLeft <= 0) {
secondsLeft += cycleSeconds;
}

this.warn(steamID, `Next rally spawn in ~${secondsLeft} seconds.`);
}

getRallyIntervalSeconds() {
const configured = this.options.rally_interval_seconds;
if (typeof configured === "number" && configured > 0) {
return configured;
}

const layer = this.server?.currentLayer;
if (layer) {
const candidates = [layer.layerid, layer.classname, layer.name];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.length > 0) {
return candidate.startsWith("SU_") ? 45 : 60;
}
}
}

return 60;
}

}