diff --git a/README.md b/README.md index 93120bf..2524a18 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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", @@ -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 } }, { diff --git a/rally-timer.js b/rally-timer.js index 217a44e..6940885 100644 --- a/rally-timer.js +++ b/rally-timer.js @@ -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, }, }; } @@ -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); @@ -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() { @@ -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(); }); @@ -128,7 +146,8 @@ 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); @@ -136,16 +155,16 @@ export default class RallyTimer extends BasePlugin { } } - // 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.`); } } } @@ -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); } } @@ -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) { @@ -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) @@ -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); } @@ -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)` ); } @@ -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 "); + 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; } + }