995 lines
32 KiB
Plaintext
995 lines
32 KiB
Plaintext
/* Persistent AFK reserve. Requires the TWS native AFK journal API and EC /api/afk. */
|
|
#include maps\_utility;
|
|
#include common_scripts\utility;
|
|
|
|
main()
|
|
{
|
|
}
|
|
|
|
init()
|
|
{
|
|
if (GetDvar(#"zombiemode") != "1")
|
|
return;
|
|
scripts\sp\tws_t5_common::ensure_config();
|
|
level.afk_url = "https://ec.thewrightserver.net/api/afk";
|
|
level.afk_server = GetDvar("tws_server_id");
|
|
level.afk_token = GetDvar("tws_bank_token");
|
|
level.afk_ready = false;
|
|
level.afk_ended = false;
|
|
level.afk_players = [];
|
|
level.afk_requests = [];
|
|
level.afk_stops = [];
|
|
level.afk_grace_seconds = 2;
|
|
level.afk_cooldown_seconds = 15;
|
|
level.afk_warning_sound = "zmb_perks_packa_ready";
|
|
if (GetDvar("tws_afk_warning_sound") != "")
|
|
level.afk_warning_sound = GetDvar("tws_afk_warning_sound");
|
|
if (GetDvar("tws_afk_cooldown_seconds") != "")
|
|
level.afk_cooldown_seconds = max(2, min(300, GetDvarInt("tws_afk_cooldown_seconds")));
|
|
|
|
scripts\sp\tws_t5_common::register_help_command(".afk", "Protected AFK using your time reserve");
|
|
scripts\sp\tws_t5_common::register_help_command(".afk balance", "Free allowance and saved time");
|
|
scripts\sp\tws_t5_common::register_help_command(".afk buy <minutes>", "Quote a bank purchase; confirmation required");
|
|
scripts\sp\tws_t5_common::register_help_command(".back", "Return from AFK");
|
|
scripts\sp\tws_t5_common::register_spawn_handler(::afk_spawn);
|
|
|
|
// Use the stock damage-callback chain, not the shared engine god-mode bit.
|
|
// At expiry this hook immediately falls through, preserving stock protection.
|
|
callbacks = [];
|
|
callbacks[0] = ::afk_damage;
|
|
if (isDefined(level.player_damage_callbacks))
|
|
for (i = 0; i < level.player_damage_callbacks.size; i++)
|
|
callbacks[callbacks.size] = level.player_damage_callbacks[i];
|
|
level.player_damage_callbacks = callbacks;
|
|
level thread afk_chat_listener();
|
|
level thread afk_startup();
|
|
level thread afk_end_watcher();
|
|
level thread afk_round_watcher();
|
|
}
|
|
|
|
afk_startup()
|
|
{
|
|
if (GetDvar("tws_afk_enabled") == "0" || level.afk_server == "" ||
|
|
level.afk_token.size < 40 || getSubStr(level.afk_token, 0, 8) != "ec_bank_")
|
|
{
|
|
println("[tws_afk] Disabled: configure tws_server_id and private tws_bank_token");
|
|
return;
|
|
}
|
|
while (!is_true(level.telemetry_started) || !isDefined(level.telemetry_match))
|
|
wait 0.1;
|
|
level.afk_match = level.telemetry_match;
|
|
if (!afkRecover(level.afk_server, level.afk_url, level.afk_match))
|
|
{
|
|
println("[tws_afk] Journal recovery failed; protected AFK disabled");
|
|
return;
|
|
}
|
|
afk_register_dogs();
|
|
level.afk_ready = true;
|
|
println("[tws_afk] Ready native=" + afkVersion());
|
|
level thread afk_sender();
|
|
}
|
|
|
|
afk_guid(player)
|
|
{
|
|
if (!isDefined(player))
|
|
return undefined;
|
|
guid = player getGuid();
|
|
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
|
|
return undefined;
|
|
guid = "" + guid;
|
|
if (guid.size < 1 || guid.size > 64)
|
|
return undefined;
|
|
allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-";
|
|
for (i = 0; i < guid.size; i++)
|
|
{
|
|
found = false;
|
|
for (j = 0; j < allowed.size; j++)
|
|
if (guid[i] == allowed[j])
|
|
found = true;
|
|
if (!found)
|
|
return undefined;
|
|
}
|
|
return guid;
|
|
}
|
|
|
|
afk_spawn()
|
|
{
|
|
self endon("disconnect");
|
|
if (isDefined(self.tws_afk_state))
|
|
return;
|
|
guid = afk_guid(self);
|
|
if (!isDefined(guid))
|
|
return;
|
|
if (!isDefined(level.afk_players[guid]))
|
|
{
|
|
state = spawnStruct();
|
|
state.guid = guid;
|
|
state.seq = 0;
|
|
state.active_ms = 0;
|
|
state.kills = 0;
|
|
state.headshots = 0;
|
|
state.revives = 0;
|
|
state.rounds = 0;
|
|
state.last_round_awarded = 0;
|
|
state.round_active_ms = 0;
|
|
state.round_contribution = false;
|
|
level.afk_players[guid] = state;
|
|
}
|
|
progress = level.afk_players[guid];
|
|
if (isDefined(progress.owner) && progress.owner.connected)
|
|
return;
|
|
// Connection state is never reused: an old HTTP completion cannot mutate a
|
|
// reconnect's session or confirmation. Only cumulative match counters persist.
|
|
state = spawnStruct();
|
|
state.guid = guid;
|
|
state.progress = progress;
|
|
progress.owner = state;
|
|
state.player = self;
|
|
state.connected = true;
|
|
state.pending = false;
|
|
state.cancelled = false;
|
|
state.session = undefined;
|
|
state.quote = undefined;
|
|
state.last_exit = -300000;
|
|
state.last_input = getTime() - 60000;
|
|
state.last_engaged = getTime() - 60000;
|
|
state.last_flush = getTime();
|
|
self.tws_afk_state = state;
|
|
self.hud_afk_on = false;
|
|
self.tws_afk_protected = false;
|
|
self.tws_afk_controls_locked = false;
|
|
self.tws_afk_ignore_owned = false;
|
|
self.tws_afk_grace_until = 0;
|
|
self.tws_afk_authorized = 0;
|
|
self.tws_afk_last_second = -1;
|
|
self afk_hud_create();
|
|
self thread afk_disconnect_watcher(state);
|
|
self thread afk_lifecycle_watcher();
|
|
self thread afk_kill_watcher();
|
|
self thread afk_revive_watcher();
|
|
self thread afk_activity_watcher();
|
|
self thread afk_tick();
|
|
}
|
|
|
|
afk_hud_create()
|
|
{
|
|
// Separate foreground elements stay visible when the optional core HUD hides.
|
|
timer = NewClientHudElem(self);
|
|
timer.horzAlign = "center";
|
|
timer.vertAlign = "middle";
|
|
timer.alignX = "center";
|
|
timer.alignY = "middle";
|
|
timer.x = 0;
|
|
timer.y = -30;
|
|
timer.font = "default";
|
|
timer.fontScale = 3.0;
|
|
timer.foreground = true;
|
|
timer.sort = 100;
|
|
timer.alpha = 0;
|
|
timer SetText("AFK");
|
|
self.hud_afk_elem = timer;
|
|
warning = NewClientHudElem(self);
|
|
warning.horzAlign = "center";
|
|
warning.vertAlign = "middle";
|
|
warning.alignX = "center";
|
|
warning.alignY = "middle";
|
|
warning.x = 0;
|
|
warning.y = 5;
|
|
warning.font = "default";
|
|
warning.fontScale = 1.3;
|
|
warning.foreground = true;
|
|
warning.sort = 100;
|
|
warning.alpha = 0;
|
|
warning SetText("Protection ends when this timer reaches zero.");
|
|
self.tws_afk_warning_elem = warning;
|
|
elems = [];
|
|
elems[0] = timer;
|
|
elems[1] = warning;
|
|
self thread scripts\sp\tws_t5_common::destroy_on_disconnect(elems);
|
|
}
|
|
|
|
afk_playing(player)
|
|
{
|
|
return isDefined(player) && !level.afk_ended && !is_true(level.intermission) &&
|
|
!is_true(level.gameended) && isAlive(player) &&
|
|
isDefined(player.sessionstate) && player.sessionstate == "playing" &&
|
|
!(player maps\_laststand::player_is_in_laststand()) &&
|
|
!is_true(player.is_zombie) && !is_true(player.afterlife);
|
|
}
|
|
|
|
afk_stock_controls(player)
|
|
{
|
|
return !afk_playing(player) || is_true(level.player_movement_suppressed) ||
|
|
isDefined(level.hostmigrationtimer) || is_true(level.host_ended_game) ||
|
|
is_true(player.hostmigrationcontrolsfrozen) ||
|
|
is_true(player.disabledweapon) || is_true(player.disabledusability) ||
|
|
is_true(player.tws_afk_external_controls);
|
|
}
|
|
|
|
afk_can_enter(player)
|
|
{
|
|
if (!afk_playing(player) || afk_stock_controls(player))
|
|
return false;
|
|
return !is_true(player.ignoreme) && !isGodMode(player);
|
|
}
|
|
|
|
afk_can_earn(player)
|
|
{
|
|
// Manual .back restores play immediately; its separate protection tail
|
|
// must not discard successful revives or other contributions after return.
|
|
return afk_playing(player) && isDefined(player.tws_afk_state) &&
|
|
!is_true(player.hud_afk_on);
|
|
}
|
|
|
|
afk_chat_listener()
|
|
{
|
|
for (;;)
|
|
{
|
|
level waittill("say", message, player);
|
|
if (!isDefined(player) || !isDefined(message))
|
|
continue;
|
|
args = strTok(toLower(message), " ");
|
|
if (args.size == 0)
|
|
continue;
|
|
if (args[0] == ".back" && args.size == 1)
|
|
{
|
|
if (isDefined(player.tws_afk_state))
|
|
{
|
|
player.tws_afk_state.cancelled = true;
|
|
player thread afk_exit();
|
|
}
|
|
continue;
|
|
}
|
|
if (args[0] != ".afk")
|
|
continue;
|
|
if (!level.afk_ready || !isDefined(player.tws_afk_state) || level.afk_ended)
|
|
{
|
|
player iPrintLnBold("AFK reserve unavailable. No bank points have been spent.");
|
|
continue;
|
|
}
|
|
state = player.tws_afk_state;
|
|
if (state.pending)
|
|
{
|
|
player iPrintLnBold("AFK request is still pending. Use .back to cancel entry.");
|
|
continue;
|
|
}
|
|
if (args.size == 1)
|
|
{
|
|
if (!is_true(player.hud_afk_on))
|
|
level thread afk_enter(player, state);
|
|
}
|
|
else if ((args.size == 2 && args[1] == "balance") ||
|
|
(args.size >= 3 && args[1] == "buy"))
|
|
{
|
|
state.pending = true;
|
|
level thread afk_account_command(player, state, args);
|
|
}
|
|
else
|
|
player iPrintLnBold("Usage: .afk | .back | .afk balance | .afk buy <minutes>");
|
|
}
|
|
}
|
|
|
|
afk_base(state, action)
|
|
{
|
|
data = [];
|
|
data["action"] = action;
|
|
data["server"] = level.afk_server;
|
|
data["guid"] = state.guid;
|
|
data["match"] = level.afk_match;
|
|
if (action != "balance")
|
|
data["request_id"] = telemetryNewMatch();
|
|
return data;
|
|
}
|
|
|
|
afk_say(state, message)
|
|
{
|
|
if (state.connected && isDefined(state.player))
|
|
state.player iPrintLnBold(message);
|
|
}
|
|
|
|
afk_minutes(text)
|
|
{
|
|
if (text.size < 1 || text.size > 4)
|
|
return 0;
|
|
digits = "0123456789";
|
|
for (i = 0; i < text.size; i++)
|
|
{
|
|
found = false;
|
|
for (j = 0; j < digits.size; j++)
|
|
if (text[i] == digits[j])
|
|
found = true;
|
|
if (!found)
|
|
return 0;
|
|
}
|
|
return int(text);
|
|
}
|
|
|
|
afk_account_command(player, state, args)
|
|
{
|
|
afk_account_request(player, state, args);
|
|
state.pending = false;
|
|
}
|
|
|
|
afk_account_request(player, state, args)
|
|
{
|
|
if (args[1] == "buy" && (is_true(player.hud_afk_on) ||
|
|
is_true(player.tws_afk_protected) || isDefined(state.session)))
|
|
{
|
|
state.quote = undefined;
|
|
afk_say(state, "Use .back before buying time. Purchases apply to your next AFK session.");
|
|
return;
|
|
}
|
|
if (args[1] == "buy" && args.size == 4 && args[3] == "confirm")
|
|
{
|
|
quote = state.quote;
|
|
state.quote = undefined;
|
|
if (!isDefined(quote) || getTime() > quote.expires ||
|
|
afk_minutes(args[2]) != quote.minutes || !state.connected)
|
|
{
|
|
afk_say(state, "Purchase confirmation expired. Request .afk buy <minutes> again.");
|
|
return;
|
|
}
|
|
data = afk_base(state, "purchase");
|
|
data["packs"] = quote.packs;
|
|
data["confirmed"] = true;
|
|
data["expected_purchase_points"] = quote.points;
|
|
data["expected_purchase_seconds"] = quote.seconds;
|
|
// Persist the same ID before sending. Retries never become a new charge.
|
|
if (!afk_enqueue(data, state))
|
|
afk_say(state, "AFK storage unavailable. No purchase was sent.");
|
|
else
|
|
afk_say(state, "Purchase submitted; checking your bank and reserve capacity.");
|
|
return;
|
|
}
|
|
if (args[1] == "buy" && args.size != 3)
|
|
{
|
|
afk_say(state, "Usage: .afk buy <minutes>, then .afk buy <minutes> confirm");
|
|
return;
|
|
}
|
|
data = afk_base(state, "balance");
|
|
reply = afk_http("balance", jsonSerialize(data), "");
|
|
if (!state.connected)
|
|
return;
|
|
if (!isDefined(reply) || reply["state"] != "balance")
|
|
{
|
|
afk_say(state, "AFK account unavailable. Existing authorized protection keeps counting down.");
|
|
return;
|
|
}
|
|
if (args[1] == "balance")
|
|
{
|
|
afk_say(state, "AFK free " + afk_format(reply["free_seconds"]) +
|
|
" | saved " + afk_format(reply["saved_seconds"]) +
|
|
" | reserved " + afk_format(reply["reserved_seconds"]));
|
|
return;
|
|
}
|
|
minutes = afk_minutes(args[2]);
|
|
seconds = minutes * 60;
|
|
if (minutes < 1 || reply["purchase_seconds"] < 1 || seconds % reply["purchase_seconds"] != 0)
|
|
{
|
|
afk_say(state, "Buy whole packs of " + afk_format(reply["purchase_seconds"]) + " each.");
|
|
return;
|
|
}
|
|
packs = int(seconds / reply["purchase_seconds"]);
|
|
if (packs > reply["max_purchase_packs"])
|
|
{
|
|
afk_say(state, "Purchase would exceed reserve capacity. No points spent.");
|
|
return;
|
|
}
|
|
quote = spawnStruct();
|
|
quote.minutes = minutes;
|
|
quote.packs = packs;
|
|
quote.points = reply["purchase_points"];
|
|
quote.seconds = reply["purchase_seconds"];
|
|
quote.expires = getTime() + 30000;
|
|
state.quote = quote;
|
|
afk_say(state, "Buy " + minutes + " minutes for " + (packs * reply["purchase_points"]) +
|
|
" bank points? Type .afk buy " + minutes + " confirm within 30 seconds.");
|
|
}
|
|
|
|
afk_enter(player, state)
|
|
{
|
|
state.pending = true;
|
|
state.cancelled = false;
|
|
afk_activate(player, state);
|
|
state.pending = false;
|
|
}
|
|
|
|
afk_activate(player, state)
|
|
{
|
|
if (!state.connected || !afk_can_enter(player))
|
|
{
|
|
afk_say(state, "AFK requires a living, standing player with normal controls.");
|
|
return;
|
|
}
|
|
if (getTime() - state.last_exit < level.afk_cooldown_seconds * 1000 ||
|
|
is_true(player.tws_afk_protected))
|
|
{
|
|
afk_say(state, "Wait briefly after returning before entering AFK again.");
|
|
return;
|
|
}
|
|
data = afk_base(state, "reserve");
|
|
data["session_id"] = telemetryNewMatch();
|
|
data["seconds"] = 604800;
|
|
session = data["session_id"];
|
|
state.session = session;
|
|
body = jsonSerialize(data);
|
|
if (!afkPrepare(level.afk_server, level.afk_url, body))
|
|
{
|
|
state.session = undefined;
|
|
afk_say(state, "AFK journal unavailable. Protection was not started.");
|
|
return;
|
|
}
|
|
reply = afk_http("reserve", body, data["request_id"]);
|
|
if (!isDefined(reply) || reply["state"] != "reserved" || reply["authorized_seconds"] <= 0 ||
|
|
reply["session_id"] != session)
|
|
{
|
|
afk_stop_state(state, "never_started");
|
|
if (isDefined(reply) && reply["code"] == "empty")
|
|
afk_say(state, "No AFK time available. Earn time through play or use .afk buy <minutes>.");
|
|
else
|
|
afk_say(state, "AFK authorization unavailable or already reserved elsewhere. Protection was not started.");
|
|
return;
|
|
}
|
|
if (!state.connected || state.cancelled || !isDefined(state.session) || state.session != session)
|
|
{
|
|
afk_stop_state(state, "never_started");
|
|
return;
|
|
}
|
|
authorized = reply["authorized_seconds"];
|
|
if (!afkAuthorize(level.afk_server, level.afk_url, state.session, authorized))
|
|
{
|
|
afk_stop_state(state, "never_started");
|
|
return;
|
|
}
|
|
afkRemove(level.afk_server, level.afk_url, data["request_id"]);
|
|
start = afk_base(state, "start");
|
|
start["session_id"] = state.session;
|
|
start_body = jsonSerialize(start);
|
|
if (!afkStore(level.afk_server, level.afk_url, start_body))
|
|
{
|
|
afk_stop_state(state, "never_started");
|
|
return;
|
|
}
|
|
reply = afk_http("start", start_body, start["request_id"]);
|
|
if (!isDefined(reply) || reply["state"] != "started" || reply["session_id"] != session ||
|
|
reply["authorized_seconds"] != authorized || !state.connected ||
|
|
state.cancelled || !afk_can_enter(player))
|
|
{
|
|
afk_stop_state(state, "never_started");
|
|
afk_say(state, "AFK entry cancelled. No protected time used.");
|
|
return;
|
|
}
|
|
afkRemove(level.afk_server, level.afk_url, start["request_id"]);
|
|
// No wait between the durable native start clock and applying protection.
|
|
if (!afkBegin(level.afk_server, level.afk_url, state.session))
|
|
{
|
|
afk_stop_state(state, "never_started");
|
|
return;
|
|
}
|
|
player.tws_afk_authorized = authorized;
|
|
player.tws_afk_next_checkpoint = getTime() + 1000;
|
|
player.tws_afk_ignore_before = player.ignoreme;
|
|
player.tws_afk_ignore_owned = true;
|
|
player.ignoreme = true;
|
|
player.hud_afk_on = true;
|
|
player.tws_afk_protected = true;
|
|
player.tws_afk_grace_until = 0;
|
|
player.tws_afk_last_second = -1;
|
|
player afk_lock_controls();
|
|
player afk_countdown();
|
|
afk_say(state, "AFK - type .back to return. Protection ends when this timer reaches zero.");
|
|
}
|
|
|
|
afk_damage(inflictor, attacker, damage, flags, mod, weapon, point, direction, hitloc, modelindex, offset)
|
|
{
|
|
if (is_true(self.tws_afk_protected) && afk_playing(self) &&
|
|
((is_true(self.hud_afk_on) && self afk_remaining() > 0) ||
|
|
(!is_true(self.hud_afk_on) && getTime() < self.tws_afk_grace_until)))
|
|
return 0;
|
|
if (damage > 0 && afk_can_earn(self))
|
|
self.tws_afk_state.last_engaged = getTime();
|
|
return -1;
|
|
}
|
|
|
|
afk_tick()
|
|
{
|
|
self endon("disconnect");
|
|
for (;;)
|
|
{
|
|
if (is_true(self.hud_afk_on))
|
|
{
|
|
if (self afk_remaining() <= 0)
|
|
{
|
|
self afk_finish("expired", false);
|
|
self iPrintLnBold("AFK time expired. Protection ended - you are vulnerable now.");
|
|
}
|
|
else if (!afk_playing(self) || afk_stock_controls(self))
|
|
self afk_finish("death", false);
|
|
else
|
|
{
|
|
// Backend connectivity is irrelevant to this local deadline.
|
|
if (getTime() >= self.tws_afk_next_checkpoint)
|
|
{
|
|
elapsed = afkCheckpoint(level.afk_server, level.afk_url, self.tws_afk_state.session);
|
|
self.tws_afk_next_checkpoint = getTime() + 1000;
|
|
if (elapsed < 0)
|
|
{
|
|
self afk_finish("manual", false);
|
|
self iPrintLnBold("AFK journal unavailable. Protection ended; unused time will reconcile.");
|
|
}
|
|
}
|
|
if (self.hud_afk_on)
|
|
{
|
|
self afk_lock_controls();
|
|
self afk_countdown();
|
|
}
|
|
}
|
|
}
|
|
else if (is_true(self.tws_afk_protected) && getTime() >= self.tws_afk_grace_until)
|
|
self afk_release();
|
|
else if (self.tws_afk_controls_locked)
|
|
self afk_restore_controls();
|
|
if (!self.tws_afk_protected && self.tws_afk_ignore_owned)
|
|
self afk_restore_targeting();
|
|
wait 0.05;
|
|
}
|
|
}
|
|
|
|
afk_remaining()
|
|
{
|
|
if (!isDefined(self.tws_afk_state.session))
|
|
return 0;
|
|
return afkRemaining(level.afk_server, level.afk_url, self.tws_afk_state.session);
|
|
}
|
|
|
|
afk_countdown()
|
|
{
|
|
remaining = int((max(0, self afk_remaining()) + 999) / 1000);
|
|
if (remaining == self.tws_afk_last_second)
|
|
return;
|
|
self.tws_afk_last_second = remaining;
|
|
self.hud_afk_elem SetTextUnlimited("AFK " + afk_format(remaining));
|
|
self.hud_afk_elem.alpha = 1;
|
|
self.tws_afk_warning_elem.alpha = 1;
|
|
self.hud_afk_elem.fontScale = 3.0;
|
|
self.hud_afk_elem.color = (1, 1, 1);
|
|
if (remaining <= 60)
|
|
{
|
|
self.hud_afk_elem.color = (1, 0.25, 0.1);
|
|
self.hud_afk_elem.fontScale = 3.5;
|
|
if (remaining == 60 || remaining == 30 || remaining <= 10)
|
|
self playLocalSound(level.afk_warning_sound);
|
|
}
|
|
}
|
|
|
|
afk_format(seconds)
|
|
{
|
|
minutes = int(seconds / 60);
|
|
seconds = int(seconds % 60);
|
|
tail = "" + seconds;
|
|
if (seconds < 10)
|
|
tail = "0" + seconds;
|
|
return minutes + ":" + tail;
|
|
}
|
|
|
|
afk_exit()
|
|
{
|
|
if (!is_true(self.hud_afk_on))
|
|
return;
|
|
if (self afk_remaining() <= 0)
|
|
{
|
|
self afk_finish("expired", false);
|
|
self iPrintLnBold("AFK time expired. Protection ended - you are vulnerable now.");
|
|
return;
|
|
}
|
|
self afk_finish("manual", true);
|
|
self iPrintLnBold("Back in the fight");
|
|
}
|
|
|
|
afk_finish(reason, manual_grace)
|
|
{
|
|
state = self.tws_afk_state;
|
|
self.hud_afk_on = false;
|
|
state.last_exit = getTime();
|
|
self.hud_afk_elem.alpha = 0;
|
|
self.tws_afk_warning_elem.alpha = 0;
|
|
self afk_restore_controls();
|
|
if (manual_grace && afk_playing(self))
|
|
self.tws_afk_grace_until = getTime() + level.afk_grace_seconds * 1000;
|
|
else
|
|
self afk_release();
|
|
// Accepted transition, never inferred from the chat text. Manual grace is
|
|
// separate from paid AFK; its two-second tail cannot be renewed by re-entry.
|
|
afk_stop_state(state, reason);
|
|
afk_flush(state);
|
|
}
|
|
|
|
afk_release()
|
|
{
|
|
self.hud_afk_on = false;
|
|
self.tws_afk_protected = false;
|
|
self.tws_afk_grace_until = 0;
|
|
self afk_restore_controls();
|
|
if (isDefined(self.hud_afk_elem))
|
|
self.hud_afk_elem.alpha = 0;
|
|
if (isDefined(self.tws_afk_warning_elem))
|
|
self.tws_afk_warning_elem.alpha = 0;
|
|
// Stock laststand/afterlife/intermission takes over ignoreme and controls.
|
|
// Our damage hook has already relinquished protection even in those states.
|
|
self afk_restore_targeting();
|
|
}
|
|
|
|
afk_restore_targeting()
|
|
{
|
|
if (self.tws_afk_ignore_owned && afk_playing(self) && !afk_stock_controls(self))
|
|
{
|
|
self.ignoreme = self.tws_afk_ignore_before;
|
|
self.tws_afk_ignore_owned = false;
|
|
}
|
|
}
|
|
|
|
afk_lock_controls()
|
|
{
|
|
self.tws_afk_controls_locked = true;
|
|
self freezeControls(true);
|
|
self disableWeapons();
|
|
self disableOffhandWeapons();
|
|
}
|
|
|
|
afk_restore_controls()
|
|
{
|
|
if (!self.tws_afk_controls_locked || afk_stock_controls(self))
|
|
return;
|
|
self.tws_afk_controls_locked = false;
|
|
self freezeControls(false);
|
|
self enableWeapons();
|
|
self enableOffhandWeapons();
|
|
}
|
|
|
|
afk_stop_state(state, reason)
|
|
{
|
|
if (!isDefined(state.session))
|
|
return;
|
|
stop = spawnStruct();
|
|
stop.session = state.session;
|
|
stop.reason = reason;
|
|
state.session = undefined;
|
|
if (!afkStop(level.afk_server, level.afk_url, stop.session, stop.reason))
|
|
level.afk_stops[level.afk_stops.size] = stop;
|
|
}
|
|
|
|
afk_disconnect_watcher(state)
|
|
{
|
|
self waittill("disconnect");
|
|
// No endon: checkpoint/stop while the departing entity is still valid.
|
|
if (is_true(self.hud_afk_on))
|
|
self afk_finish("disconnect", false);
|
|
else
|
|
afk_stop_state(state, "never_started");
|
|
state.connected = false;
|
|
state.cancelled = true;
|
|
afk_flush(state);
|
|
}
|
|
|
|
afk_lifecycle_watcher()
|
|
{
|
|
self endon("disconnect");
|
|
for (;;)
|
|
{
|
|
self waittill_any("killed_player", "spawned_player");
|
|
if (is_true(self.hud_afk_on))
|
|
self afk_finish("death", false);
|
|
else if (is_true(self.tws_afk_protected))
|
|
self afk_release();
|
|
self.tws_afk_state.cancelled = true;
|
|
}
|
|
}
|
|
|
|
afk_end_watcher()
|
|
{
|
|
level waittill_any("end_game", "game_ended", "intermission");
|
|
// Stop without yielding before stock intermission takes control.
|
|
players = getPlayers();
|
|
for (i = 0; i < players.size; i++)
|
|
{
|
|
if (!isDefined(players[i].tws_afk_state))
|
|
continue;
|
|
if (is_true(players[i].hud_afk_on))
|
|
players[i] afk_finish("match_end", false);
|
|
else if (is_true(players[i].tws_afk_protected))
|
|
players[i] afk_release();
|
|
players[i].tws_afk_state.cancelled = true;
|
|
afk_flush(players[i].tws_afk_state);
|
|
}
|
|
level.afk_ended = true;
|
|
}
|
|
|
|
// Both stock engines notify the credited attacker, with the actual dead zombie.
|
|
// Never infer contributions from spendable score, score_total, or bank actions.
|
|
afk_kill_watcher()
|
|
{
|
|
self endon("disconnect");
|
|
for (;;)
|
|
{
|
|
self waittill("zom_kill", zombie);
|
|
afk_credit_kill(self, zombie);
|
|
}
|
|
}
|
|
|
|
afk_credit_kill(player, zombie)
|
|
{
|
|
if (!afk_can_earn(player) || !isDefined(zombie) ||
|
|
!isDefined(zombie.attacker) || zombie.attacker != player ||
|
|
is_true(zombie.tws_afk_rewarded))
|
|
return;
|
|
zombie.tws_afk_rewarded = true;
|
|
state = player.tws_afk_state;
|
|
state.progress.kills++;
|
|
if (afk_headshot(zombie))
|
|
state.progress.headshots++;
|
|
state.progress.round_contribution = true;
|
|
state.last_engaged = getTime();
|
|
// Cumulative kill/headshot totals are journaled by the 30-second flush.
|
|
}
|
|
|
|
afk_headshot(zombie)
|
|
{
|
|
if (!isDefined(zombie.damagelocation) || !isDefined(zombie.damagemod) ||
|
|
!isDefined(zombie.damageweapon))
|
|
return false;
|
|
// Count lethal head/helmet hits, not T5's nonlethal head-gib statistic.
|
|
return (zombie.damagelocation == "head" || zombie.damagelocation == "helmet") &&
|
|
zombie.damagemod != "MOD_MELEE" && zombie.damagemod != "MOD_BAYONET" &&
|
|
zombie.damagemod != "MOD_UNKNOWN" && zombie.damagemod != "MOD_IMPACT";
|
|
}
|
|
|
|
// The revived teammate owns this notification. A successful repeat revive is a
|
|
// new event every time; no teammate cache, cooldown, or reward limiter belongs here.
|
|
afk_revive_watcher()
|
|
{
|
|
self endon("disconnect");
|
|
for (;;)
|
|
{
|
|
self waittill("player_revived", reviver);
|
|
if (!isDefined(reviver) || reviver == self || !afk_can_earn(reviver))
|
|
continue;
|
|
state = reviver.tws_afk_state;
|
|
state.progress.revives++;
|
|
state.progress.round_contribution = true;
|
|
state.last_engaged = getTime();
|
|
afk_flush(state);
|
|
}
|
|
}
|
|
|
|
afk_round_watcher()
|
|
{
|
|
for (;;)
|
|
{
|
|
level waittill("end_of_round");
|
|
players = getPlayers();
|
|
for (i = 0; i < players.size; i++)
|
|
{
|
|
player = players[i];
|
|
if (!isDefined(player.tws_afk_state))
|
|
continue;
|
|
state = player.tws_afk_state;
|
|
if (afk_can_earn(player) && state.progress.last_round_awarded != level.round_number &&
|
|
(state.progress.round_contribution || state.progress.round_active_ms >= 30000))
|
|
{
|
|
state.progress.rounds++;
|
|
state.progress.last_round_awarded = level.round_number;
|
|
afk_flush(state);
|
|
}
|
|
}
|
|
identities = getArrayKeys(level.afk_players);
|
|
for (i = 0; i < identities.size; i++)
|
|
{
|
|
progress = level.afk_players[identities[i]];
|
|
progress.round_active_ms = 0;
|
|
progress.round_contribution = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
afk_activity_watcher()
|
|
{
|
|
self endon("disconnect");
|
|
state = self.tws_afk_state;
|
|
previous_origin = self.origin;
|
|
previous_angles = self getPlayerAngles();
|
|
previous_buttons = self afk_buttons();
|
|
previous_time = getTime();
|
|
for (;;)
|
|
{
|
|
wait 1;
|
|
now = getTime();
|
|
elapsed = min(1000, max(0, now - previous_time));
|
|
angles = self getPlayerAngles();
|
|
buttons = self afk_buttons();
|
|
if (afk_can_earn(self))
|
|
{
|
|
changed = distanceSquared(previous_origin, self.origin) > 16 ||
|
|
distanceSquared(previous_angles, angles) > 4 || buttons != previous_buttons;
|
|
if (changed)
|
|
{
|
|
state.last_input = now;
|
|
state.last_engaged = now;
|
|
}
|
|
// A stationary revive is real support. Holding a zombie also counts
|
|
// with recent deliberate input; simply being targeted indefinitely
|
|
// or leaving a button held does not constitute active play.
|
|
if (self afk_supporting() ||
|
|
(now - state.last_input <= 60000 && self afk_holding_zombie()))
|
|
state.last_engaged = now;
|
|
if (now - state.last_engaged <= 15000)
|
|
{
|
|
state.progress.active_ms += elapsed;
|
|
state.progress.round_active_ms += elapsed;
|
|
}
|
|
}
|
|
previous_origin = self.origin;
|
|
previous_angles = angles;
|
|
previous_buttons = buttons;
|
|
previous_time = now;
|
|
if (now - state.last_flush >= 30000)
|
|
afk_flush(state);
|
|
}
|
|
}
|
|
|
|
afk_buttons()
|
|
{
|
|
return (self useButtonPressed()) + 2 * (self attackButtonPressed()) +
|
|
4 * (self adsButtonPressed()) + 8 * (self meleeButtonPressed());
|
|
}
|
|
|
|
afk_supporting()
|
|
{
|
|
if (!(self useButtonPressed()))
|
|
return false;
|
|
players = getPlayers();
|
|
for (i = 0; i < players.size; i++)
|
|
if (players[i] != self &&
|
|
(players[i] maps\_laststand::player_is_in_laststand()) &&
|
|
isDefined(players[i].revivetrigger) &&
|
|
(self maps\_laststand::is_reviving(players[i])))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
afk_holding_zombie()
|
|
{
|
|
zombies = GetAiSpeciesArray("axis", "all");
|
|
for (i = 0; i < zombies.size; i++)
|
|
if (isAlive(zombies[i]) && isDefined(zombies[i].enemy) && zombies[i].enemy == self &&
|
|
distanceSquared(self.origin, zombies[i].origin) <= 262144)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
afk_flush(state)
|
|
{
|
|
if (!level.afk_ready || !isDefined(level.afk_match))
|
|
return;
|
|
data = afk_base(state, "event");
|
|
state.progress.seq++;
|
|
data["seq"] = state.progress.seq;
|
|
data["active_seconds"] = int(state.progress.active_ms / 1000);
|
|
data["kills"] = state.progress.kills;
|
|
data["headshots"] = state.progress.headshots;
|
|
data["rounds"] = state.progress.rounds;
|
|
data["revives"] = state.progress.revives;
|
|
data["activity"] = "playing";
|
|
if (afk_enqueue(data, undefined))
|
|
state.last_flush = getTime();
|
|
}
|
|
|
|
afk_enqueue(data, state)
|
|
{
|
|
if (!isDefined(data["request_id"]))
|
|
return false;
|
|
if (!afkStore(level.afk_server, level.afk_url, jsonSerialize(data)))
|
|
return false;
|
|
if (isDefined(state))
|
|
level.afk_requests[data["request_id"]] = state;
|
|
return true;
|
|
}
|
|
|
|
afk_sender()
|
|
{
|
|
for (;;)
|
|
{
|
|
pending_stops = [];
|
|
for (i = 0; i < level.afk_stops.size; i++)
|
|
{
|
|
stop = level.afk_stops[i];
|
|
if (!afkStop(level.afk_server, level.afk_url, stop.session, stop.reason))
|
|
pending_stops[pending_stops.size] = stop;
|
|
}
|
|
level.afk_stops = pending_stops;
|
|
entries = afkLoad(level.afk_server, level.afk_url);
|
|
if (isDefined(entries))
|
|
for (i = 0; i < entries.size; i++)
|
|
{
|
|
entry = entries[i];
|
|
// Never activate orphaned protection from a background retry.
|
|
if (entry["action"] == "reserve" || entry["action"] == "start")
|
|
continue;
|
|
reply = afk_http(entry["action"], entry["body"], entry["id"]);
|
|
if (!isDefined(reply))
|
|
break;
|
|
if (isDefined(level.afk_requests[entry["id"]]))
|
|
{
|
|
state = level.afk_requests[entry["id"]];
|
|
if (reply["state"] == "purchased")
|
|
afk_say(state, "Purchased AFK reserve. Saved: " + afk_format(reply["saved_seconds"]));
|
|
else
|
|
afk_say(state, "AFK request: " + reply["code"] + ". Use .afk balance to check your account.");
|
|
level.afk_requests[entry["id"]] = undefined;
|
|
}
|
|
if (!afkRemove(level.afk_server, level.afk_url, entry["id"]))
|
|
break;
|
|
}
|
|
wait 1;
|
|
}
|
|
}
|
|
|
|
afk_http(action, body, request_id)
|
|
{
|
|
headers = [];
|
|
headers["Content-Type"] = "application/json";
|
|
headers["Authorization"] = "Bearer " + level.afk_token;
|
|
options = [];
|
|
options["method"] = "POST";
|
|
options["headers"] = headers;
|
|
options["body"] = body;
|
|
options["connect_timeout_ms"] = 5000;
|
|
options["timeout_ms"] = 15000;
|
|
request = http::request(level.afk_url + "/" + action, options);
|
|
if (!isDefined(request))
|
|
return undefined;
|
|
request waittill("done", response, success, error, diagnostics);
|
|
if (!isDefined(success) || !success || !isDefined(response) ||
|
|
!isDefined(diagnostics) || !isDefined(diagnostics["http_status"]))
|
|
return undefined;
|
|
status = diagnostics["http_status"];
|
|
if (status < 200 || status >= 500 || status == 429)
|
|
return undefined;
|
|
reply = afkResponse(response);
|
|
if (!isDefined(reply) || !reply["valid"] || reply["request_id"] != request_id ||
|
|
(status == 409 && reply["code"] == "not_in_match"))
|
|
return undefined;
|
|
return reply;
|
|
}
|
|
|
|
// T5 hellhounds do not emit zom_kill; attach to the stock dog spawner list.
|
|
// T6 dog_init already installs _zm_spawner::zombie_death_event and needs no extra hook.
|
|
afk_register_dogs()
|
|
{
|
|
spawners = getEntArray("zombie_dog_spawner", "script_noteworthy");
|
|
later = getEntArray("later_round_dog_spawners", "script_noteworthy");
|
|
for (i = 0; i < later.size; i++)
|
|
spawners[spawners.size] = later[i];
|
|
for (i = 0; i < spawners.size; i++)
|
|
spawners[i] add_spawn_function(::afk_dog_spawn);
|
|
// Also cover a late module startup after a dog has already spawned.
|
|
dogs = GetAiSpeciesArray("axis", "all");
|
|
for (i = 0; i < dogs.size; i++)
|
|
if (isDefined(dogs[i].animname) && dogs[i].animname == "zombie_dog")
|
|
dogs[i] afk_dog_spawn();
|
|
}
|
|
|
|
afk_dog_spawn()
|
|
{
|
|
if (is_true(self.tws_afk_dog_bound))
|
|
return;
|
|
self.tws_afk_dog_bound = true;
|
|
self thread afk_dog_death();
|
|
}
|
|
|
|
afk_dog_death()
|
|
{
|
|
self waittill("death");
|
|
if (isDefined(self.attacker))
|
|
afk_credit_kill(self.attacker, self);
|
|
}
|