Show personal map records on join and replay

This commit is contained in:
2026-10-19 14:11:54 -04:00
parent 9ef5932d36
commit e93182cd02
11 changed files with 620 additions and 200 deletions
+21 -15
View File
@@ -13,7 +13,7 @@ private server token.
| `tws_t6_lock.gsc` | Auto-lock at round 20 plus majority `.lock` / `.unlock` votes; clear on game over | Common, HUD |
| `tws_t6_telemetry.gsc` | Async queued game reports (game_start/heartbeat/round_change/game_end) | tws-t6-gsc-utils |
| `tws_t6_join.gsc` | `.join` chat command lets a spectator newcomer enter mid-match once | Common |
| `tws_t6_leaderboard.gsc` | Private map record on connection, `.leaders` replay and adaptive round-start reminders | Common 1.5.0+, tws-t6-gsc-utils |
| `tws_t6_leaderboard.gsc` | Private map and personal records on connection, `.leaders` replay and adaptive round-start reminders | Common 1.6.1+, tws-t6-gsc-utils |
| `tws_t6_bank.gsc` | Persistent shared GUID bank | Common, telemetry, TWS plugin 1.2.0 |
| `tws_t6_moderation.gsc` | Optional controller-managed bans, mutes and live moderation | Common 1.2.0+, TWS plugin 1.3.0+ |
@@ -96,14 +96,16 @@ continue updating while hidden; game-over values remain frozen. Help, records
and the stock HUD are unaffected. `.hud` requires the HUD module.
Common provides one unboxed upper-right service area below the Plutonium tag.
Records are right aligned, with `Map record | Round N` followed by up to three
holder lines; there is no replay footer or single-page counter. Help displays
Records are right aligned: `Map Record | Round N`, every tied holder on its own
line, then a blank row and a separate `Your Best | Round N` heading. The whole
record is shown at once for 12 seconds, with no pages, counter or footer. Help displays
the installed command list in pages of nine rows in aligned rows. Services remain private, leave the
core in place and work without the HUD module installed. See the
[shared layout and validation guide](../../README.md#core-hud-and-service-window-t5-and-t6).
Only the active service view holds HUD elements: 19 for nine-command help
(20 with a multi-page counter; the standard AFK command set uses multiple pages), four for records (five with a multi-page
counter), or two for compact round-start reminders. Pages reuse elements; switching views or closing the service releases
(20 with a multi-page counter), one element per holder plus two headings for
full records, or two for compact round-start reminders. Help pages reuse elements;
switching views or closing the service releases
them. Idle services hold no HUD elements, while the controller retains pending
requests and interruption/resume behavior. Core + AFK use 10 + 2 elements.
@@ -112,8 +114,8 @@ HUD `1.4.2` requires common `1.4.0+` and
`SetTextUnlimited` counter, updated only when the count changes. Deploy the
updated HUD script and restart the map on r5334+; no full server-process
restart is required. Older Plutonium revisions need client/server updates and
full restarts first. Leaderboard `1.2.0` requires common `1.5.0+` for its
round-start reminders. AFK `2.0.0` requires the matching
full restarts first. Leaderboard `1.3.1` requires common `1.6.1+` for its
personal records and round-start reminders. AFK `2.0.0` requires the matching
plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
## Map record announcements
@@ -122,7 +124,7 @@ plugin `1.4.0+` and EC reserve service; see the [AFK guide](../../docs/afk.md).
It works independently of telemetry and HUD, and privately announces records
once per connection, including newcomers waiting in spectator mode.
Leaderboard `1.2.0` requires common `1.5.0+`. Copy both scripts to
Leaderboard `1.3.1` requires common `1.6.1+`. Copy both scripts to
`Plutonium/storage/t6/scripts/zm/` and restart the map. This script update adds
no native-plugin or backend dependency and requires no full server-process
restart when the existing plugin is installed.
@@ -131,10 +133,14 @@ Round-start reminders use the latest usable cache at gaps of 20, 15, 10, 8 and
6 rounds, then every round for the final five, followed by matching/overtake
milestones. After the first overtake, reminders stop for that match; `.leaders`
still works. A reminder shows the record and one short status line for six
seconds. Join and manual announcements keep all holders and 12-second pages.
seconds. Join and manual announcements show your personal best on the exact map variant
below all holders, with one player per line and a blank row before the personal
heading. Everything is visible at once for 12 seconds, with no record pages or
counter. New players see `Your Best | None yet`; failed personal lookups show
`Your Best | Unavailable`.
Reminders skip busy help/record views, unready players, recent welcome/manual
requests and empty/unavailable/expired records. Help discards a reminder instead
of pausing it. No extra HTTP requests are made. Follow the shared live checks
of pausing it. Round reminders make no extra HTTP requests. Follow the shared live checks
for cadence, interruption, cleanup and operation without the optional HUD.
Map selection uses stock `level.scr_zm_ui_gametype` and
@@ -168,7 +174,7 @@ explicit row positions so T5 does not concatenate newline-separated text.
| `.afk` | Enter protected AFK using your time reserve |
| `.back` | Return from AFK |
| `.join` | Join mid-round (newcomers only) |
| `.leaders` | Show the map record again (leaderboard module required) |
| `.leaders` | Show map and personal records (leaderboard module required) |
| `.b` | Check bank balance (bank module required) |
| `.d <amount\|all>` | Deposit points (bank module required) |
| `.w <amount\|all>` | Withdraw points (bank module required) |
@@ -184,9 +190,9 @@ Lock and extended AFK commands appear on additional pages. Bank entries retain
the full `.d <amount|all>` / `.w <amount|all>` syntax. Removing HUD removes `.hud`
from the list.
The enabled leaderboard module registers `.leaders`, which replays the latest
cached map record privately. Repeating it restarts the record from the first page;
help pauses the record's remaining page time and resumes it after the final
help page. `.leaders` during help replaces the pending record replay from page one.
map and personal records privately. Repeating it restarts the full 12-second
announcement; help pauses the record's remaining time and resumes it after the
final help page. `.leaders` during help replaces the pending record replay.
## Defaults, matching T5
@@ -288,7 +294,7 @@ another mod installed one first.
## Validation
Run the shared [portable and live HUD checks](../../README.md#hud-validation)
for core placement, service pagination, record interruptions and command regressions.
for core placement, help pagination, record interruptions and command regressions.
To compile all nine scripts for T6 PC with gsc-tool:
+38 -17
View File
@@ -175,18 +175,16 @@ show_help()
}
// An undefined snapshot is the explicit unavailable view for manual requests.
show_record(snapshot)
show_record(snapshot, personal)
{
if (isDefined(level.tws_service_ended))
return;
self service_ensure();
state = self.tws_service;
service_advance(state, getTime());
count = 0;
if (isDefined(snapshot))
count = snapshot.lines.size;
state.generation++;
state.record = service_request("record", service_page_count(count, 3), state.generation);
state.record = service_request("record", 1, state.generation);
state.record.personal = personal;
state.record.snapshot = snapshot;
service_render(state);
}
@@ -344,12 +342,21 @@ service_ensure()
self thread service_end_game(state);
}
service_record_rows(request)
{
if (request.kind == "reminder")
return 1;
if (request.kind == "record" && isDefined(request.snapshot) && request.snapshot.round > 0)
return request.snapshot.lines.size;
return 0;
}
// Allocate only the active view. Alpha-zero elements still occupy HUD slots.
service_view_create(state, request)
{
service_view_destroy(state);
state.view_kind = request.kind;
state.view_paged = request.pages > 1;
state.view_paged = request.kind == "help" && request.pages > 1;
if (state.view_paged)
state.page_counter = service_text(self, state, level.hud_service_right,
level.hud_service_title_y, "right", 1.0, "", level.hud_c_dim);
@@ -375,15 +382,16 @@ service_view_create(state, request)
state.record_title = service_text(self, state, level.hud_service_right,
level.hud_service_record_y, "right", 1.1, "", level.hud_c_count);
rows = 3;
if (request.kind == "reminder")
rows = 1;
rows = service_record_rows(request);
for (i = 0; i < rows; i++)
state.rows[i] = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + i * level.hud_service_holders_pitch,
"right", 1.05, "", level.hud_c_label);
if (isDefined(state.page_counter))
state.page_counter.y = level.hud_service_holders_y + 3 * level.hud_service_holders_pitch;
// Give the personal record its own heading after every holder and a blank row.
if (isDefined(request.personal))
state.personal_title = service_text(self, state, level.hud_service_right,
level.hud_service_holders_y + (rows + 1) * level.hud_service_holders_pitch,
"right", 1.1, "", level.hud_c_count);
}
service_view_destroy(state)
@@ -401,6 +409,8 @@ service_view_destroy(state)
state.command_rows = [];
state.description_rows = [];
state.record_title = undefined;
state.personal_title = undefined;
state.personal_text = undefined;
state.rows = [];
state.page_text = undefined;
state.record_header_text = undefined;
@@ -441,8 +451,10 @@ service_render(state)
service_view_destroy(state);
return;
}
paged = request.pages > 1;
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged)
paged = request.kind == "help" && request.pages > 1;
if (!isDefined(state.view_kind) || state.view_kind != request.kind || state.view_paged != paged ||
state.rows.size != service_record_rows(request) ||
isDefined(state.personal_title) != isDefined(request.personal))
service_view_create(state, request);
for (i = 0; i < state.elems.size; i++)
state.elems[i].alpha = 0;
@@ -478,13 +490,13 @@ service_render(state)
return;
}
header = "Map record unavailable";
header = "Map Record unavailable";
snapshot = request.snapshot;
if (isDefined(snapshot))
{
header = "No recorded high round yet";
if (snapshot.round > 0)
header = "Map record | Round " + snapshot.round;
header = "Map Record | Round " + snapshot.round;
}
if (!isDefined(state.record_header_text) || state.record_header_text != header)
{
@@ -492,6 +504,15 @@ service_render(state)
state.record_header_text = header;
}
state.record_title.alpha = 1;
if (isDefined(request.personal))
{
if (!isDefined(state.personal_text) || state.personal_text != request.personal)
{
state.personal_title SetText(request.personal);
state.personal_text = request.personal;
}
state.personal_title.alpha = 1;
}
if (!isDefined(snapshot) || snapshot.round == 0)
return;
if (request.kind == "reminder")
@@ -500,9 +521,9 @@ service_render(state)
state.rows[0].alpha = 1;
return;
}
for (i = 0; i < 3; i++)
for (i = 0; i < state.rows.size; i++)
{
index = page * 3 + i;
index = i;
if (index < snapshot.lines.size)
{
text = snapshot.lines[index];
+116 -26
View File
@@ -1,4 +1,4 @@
/* tws_t6_leaderboard.gsc - private map records, round reminders and .leaders replay. */
/* tws_t6_leaderboard.gsc - private map/personal records, round reminders and .leaders replay. */
#include maps\mp\_utility;
#include common_scripts\utility;
@@ -27,7 +27,7 @@ init()
level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0;
level.tws_lb_chase_finished = undefined;
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show the map record again");
scripts\zm\tws_t6_common::register_help_command(".leaders", "Show map and personal records");
scripts\zm\tws_t6_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker();
@@ -215,14 +215,113 @@ lb_request(manual)
// This also applies when the cache is unavailable.
if (manual)
self notify("tws_lb_requested");
self endon("tws_lb_requested");
self.tws_lb_last_request = getTime();
snapshot = lb_cached_record();
if (!isDefined(snapshot) && !manual)
return;
// Common owns pagination, help priority and the single service window.
personal = lb_personal_record(self, snapshot);
self.tws_lb_last_request = getTime();
// Common owns display lifetime, help priority and the single service window.
// Undefined snapshots produce a private unavailable page for manual replay.
self scripts\zm\tws_t6_common::show_record(snapshot);
self scripts\zm\tws_t6_common::show_record(snapshot, personal);
}
// Cache per connection; a failed lookup is unavailable, never an empty history.
lb_personal_record(player, snapshot)
{
if (!isDefined(snapshot))
return "Your Best | Unavailable";
if (snapshot.round == 0)
return "Your Best | None yet";
guid = lb_player_guid(player);
if (!isDefined(guid))
return "Your Best | Unavailable";
cached = player.tws_lb_personal;
if (isDefined(cached) && cached.key == snapshot.key && cached.guid == guid &&
getTime() - cached.saved_at < cached.ttl)
return cached.text;
round = lb_personal_fetch(snapshot.key, guid);
cached = spawnStruct();
cached.key = snapshot.key;
cached.guid = guid;
cached.saved_at = getTime();
cached.ttl = 30000;
cached.text = "Your Best | Unavailable";
if (isDefined(round))
{
cached.ttl = 300000;
cached.text = "Your Best | None yet";
if (round > 0)
cached.text = "Your Best | Round " + round;
}
player.tws_lb_personal = cached;
return cached.text;
}
lb_player_guid(player)
{
guid = player.guid;
if (!isDefined(guid) || (typeof(guid) != "string" && typeof(guid) != "int"))
return undefined;
guid = "" + guid;
if (guid.size == 0 || guid.size > 64)
return undefined;
return guid;
}
// Search also matches names and GUID substrings. Only exact string identity
// with matching game/map evidence can supply this player's personal best.
lb_personal_fetch(key, guid)
{
offset = 0;
total = -1;
highest = -1;
started = getTime();
for (;;)
{
// Bound broad substring searches; incomplete searches are unavailable.
if (getTime() - started >= 15000)
return undefined;
page = lb_get("&map=" + lb_url_encode(key) + "&q=" + lb_url_encode(guid) + "&limit=3&offset=" + offset);
if (!isDefined(page) || !lb_page_valid(page, offset, 3))
return undefined;
if (offset == 0)
{
total = page["total"];
highest = page["summary"]["highestRound"];
}
if (page["total"] != total || page["summary"]["highestRound"] != highest ||
offset + page["entries"].size > total)
return undefined;
for (i = 0; i < page["entries"].size; i++)
{
entry = page["entries"][i];
if (typeof(entry) != "array" || typeof(entry["guid"]) != "string")
return undefined;
if (entry["guid"] != guid)
continue;
if (!lb_uint(entry["highestRound"]) || entry["highestRound"] < 2 ||
entry["highestRound"] > highest || typeof(entry["records"]) != "array")
return undefined;
evidence = entry["records"]["highestRound"];
if (typeof(evidence) != "array" || typeof(evidence["game"]) != "string" ||
typeof(evidence["mapKey"]) != "string" || !lb_uint(evidence["value"]))
return undefined;
if (evidence["game"] != level.tws_lb_game || evidence["mapKey"] != key ||
evidence["value"] != entry["highestRound"])
return undefined;
return entry["highestRound"];
}
offset += page["entries"].size;
if (offset == total)
return 0;
if (page["entries"].size == 0 || offset > 1000000)
return undefined;
wait 0.05;
}
}
lb_refresh()
@@ -242,6 +341,7 @@ lb_refresh()
return undefined;
snapshot = spawnStruct();
snapshot.key = selection.key;
snapshot.name = lb_display_text(selection.name, 64);
snapshot.round = 0;
snapshot.names = [];
@@ -336,9 +436,18 @@ lb_t6_selection(selection, mode, location)
return undefined;
}
// All HTTP and parsing work belongs to the level worker, never the join thread.
// Map refreshes and personal lookups share one native request slot.
lb_get(query)
{
// A disconnect/replay can abandon a caller, but the collector still drains
// its native request. Bound queue wait and never overlap that request.
queued_at = getTime();
while (isDefined(level.tws_lb_pending))
{
if (getTime() - queued_at >= 15000)
return undefined;
wait 0.1;
}
options = [];
options["method"] = "GET";
headers = [];
@@ -510,28 +619,9 @@ lb_uint(value)
lb_holder_lines(names)
{
lines = [];
line = "";
columns = 0;
// Each tied player gets a separate row in the full record announcement.
for (i = 0; i < names.size; i++)
{
name = lb_holder_name(names[i]);
width = lb_holder_width(name);
if (line != "" && columns + width + 2 > 16)
{
lines[lines.size] = line;
line = "";
columns = 0;
}
if (line != "")
{
line += ", ";
columns += 2;
}
line += name;
columns += width;
}
if (line != "")
lines[lines.size] = line;
lines[lines.size] = lb_holder_name(names[i]);
return lines;
}