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
+44
View File
@@ -6,6 +6,50 @@ T5/T6 script and each native plugin. Current versions are recorded in
## 2026-10-18
### Show each player's personal map record on join and replay
| Component | Version | Affected source |
| --- | --- | --- |
| `tws_t5_leaderboard.gsc` | `1.3.1` | `t5/scripts/tws_t5_leaderboard.gsc` |
| `tws_t6_leaderboard.gsc` | `1.3.1` | `t6/scripts/tws_t6_leaderboard.gsc` |
| `tws_t5_common.gsc` | `1.6.1` | `t5/scripts/tws_t5_common.gsc` |
| `tws_t6_common.gsc` | `1.6.1` | `t6/scripts/tws_t6_common.gsc` |
- Add a private `Your Best | Round N` heading to join announcements and `.leaders`,
using the same resolved map variant as the fleet record. Distinguish confirmed
empty history (`None yet`) from failed/malformed lookups (`Unavailable`).
- Show `Map Record | Round N`, every tied holder on a separate line, a blank row,
then the personal heading. Both headings use the brighter heading colour and
size, distinct from player names. Remove record pagination and counters: show
the entire record for 12 seconds, retaining help priority and pause/resume.
Allocate one element per holder plus the two headings, resize when the holder
count changes, and release the complete view on expiry, interruption or cleanup.
Help retains its existing command pagination; compact reminders are unchanged.
- Query the existing public leaderboard search by GUID, paginate past name and
substring matches, and accept only exact string identity with matching game/map
evidence. Cache successes per connection for five minutes and failures for
30 seconds. Bound pagination time and queue waits; serialize personal and map
HTTP requests, retaining the native slot until collection even after a caller
times out or disconnects. New manual replays cancel older waiting announcements.
- Apply matching behavior to T5 and T6 using their existing GUID access and HTTP
APIs. Round reminders still use only the map cache. Common's new personal text
argument is optional for existing consumers.
- Compatibility: leaderboard `1.3.1` requires matching common `1.6.1+`. No new
backend, native-plugin or upstream dependency requirement.
- Validation: 177 leaderboard, 76 service HUD, 14 core HUD, 42 bank, 17 join,
23 telemetry, 76 lock and 47 AFK checks passed, plus both moderation checks.
Record fixtures verify all-holder rendering, heading order/style/spacing, no
record counter, resizing, expiry and help interruption.
Changed T6 scripts compiled and assembled with upstream gsc-tool `8f1d6bd`
libraries. These are engine-shim checks; live Windows/Plutonium rendering,
concurrent joins and backend integration remain unverified. T5 has no gsc-tool
backend. JSON uniqueness, script manifest coverage, matching versions/changelog,
changed documentation's local links and `git diff --check` passed.
- Deployment: copy the updated leaderboard and common scripts together for each
installed game and restart the map. No full server-process restart or additional
deployment action is required. No deployment was performed.
### Add majority votes to lock and unlock the lobby
| Component | Version | Affected source |
+64 -34
View File
@@ -458,12 +458,13 @@ count appears only when more than one page is needed. Each page lasts
command restarts page one. Both engines position rows explicitly, avoiding
T5's newline-rendering problem.
Records use the same service area, right aligned: `Map record | Round N`
followed by up to three bounded holder lines. Single-page records have no
counter or footer; ties spanning multiple pages retain a page count and
12 seconds per page. Help pauses a visible record and resumes its remaining
page time when help ends. `.leaders` during help replaces the pending record
with a fresh replay from page one. Round-start reminders use just the record
Records use the same service area, right aligned: `Map Record | Round N`,
followed by every tied holder on a separate line. After a blank row,
`Your Best | Round N` uses the same heading style as the map record, distinct
from the holder names. The entire announcement is visible at once for 12 seconds;
records have no pages, counter or footer. Help pauses a visible record and
resumes its remaining time when help ends. `.leaders` during help replaces the
pending record with a fresh replay. Round-start reminders use just the record
header and one proximity or milestone line for six seconds. They skip busy
services; opening help discards a reminder instead of resuming it later.
One service is visible at a time. Services remain private and work when the
@@ -472,7 +473,8 @@ HUD module is omitted.
Both games allocate only the active service view and release it when idle or
before switching between help and records. Pages reuse the active view's
elements. The core uses 10 archived elements, plus two independent elements for AFK. A nine-row help page uses 19 nonarchived elements, or 20 with a multi-page
counter; records use four, or five with a multi-page counter. Hidden elements
counter; full records use one element per holder plus the map and personal
headings. Hidden elements
still occupy slots. Compact reminders use two nonarchived elements.
The controller and pending requests are separate from
display lifetime, so an idle service holds no HUD elements.
@@ -484,7 +486,8 @@ avoiding dynamic counter strings in the text config-string pool. Numeric HP
still uses `SetValue`. On r5334+, deploy the updated HUD script and restart
the map; no full server-process restart is required. On older Plutonium
revisions, update clients and server and fully restart them before deploying.
Leaderboard `1.2.0` requires matching common `1.5.0+` for round-start reminders.
Leaderboard `1.3.1` requires matching common `1.6.1+` for personal records
and round-start reminders.
AFK `2.0.0` requires plugin `1.4.0+`,
common `1.4.0+` and the EC reserve service; see the [AFK guide](docs/afk.md). Existing
leaderboard plugin requirements still apply. Common retains its older panel
@@ -526,10 +529,9 @@ Before rollout, validate both engines in Plutonium:
exercise the nine-row page limit, page counter and repeat-to-restart behavior.
Remove optional modules and restart the map to confirm filtering, including
`.hud` disappearing without HUD, then restore them.
4. Interrupt records with help and check that the interrupted page resumes for
4. Interrupt records with help and check that the full announcement resumes for
its remaining time. Repeat `.leaders` while help is open; only the latest
replay should start, at page one. Check long holder names, ties spanning
pages, no records, and unavailable records. Repeat without the HUD installed.
replay should start. Check long holder names, all tied holders visible at once, no records, and unavailable records. Repeat without the HUD installed.
5. Disconnect or end the game while help/records are active, reconnect and
request them again. Verify no duplicate elements, stale timeout closures or
surviving service windows. Confirm `.join`, bank commands and lobby lock
@@ -548,13 +550,14 @@ Before rollout, validate both engines in Plutonium:
## Map record announcements
Install `tws_t5_leaderboard.gsc` for T5 or `tws_t6_leaderboard.gsc` for T6,
**along with common `1.5.0+`** for that game when using leaderboard `1.2.0`.
**along with common `1.6.1+`** for that game when using leaderboard `1.3.1`.
Copy both scripts for each installed game and restart the map. This update
adds no native-plugin or backend requirement and needs no full server-process
restart when existing leaderboard dependencies are already installed.
The module privately announces the current map's fleet-wide highest recorded
round and every tied holder once per connection, including spectator newcomers.
Type `.leaders` to replay the record privately from the latest usable cache.
round, every tied holder and the joining player’s personal best on that map once
per connection, including spectator newcomers. Type `.leaders` to replay both
records privately. Personal bests use the same game and exact map variant.
Repeating the command restarts the announcement from its first page. It appears
in `.help` when the leaderboard module is enabled. If no usable record is
available, the service identifies the record as unavailable; this differs from
@@ -580,18 +583,32 @@ exist in the API's map list and match the current game and engine map. Clear it
when changing variants; restart the map after changing these settings.
The announcement starts five seconds after player readiness and waits for the
initial record fetch if necessary. It appears as right-aligned text in the
common-owned upper-right service area: `Map record | Round N`, followed by up
to three holder lines. There is no background or replay footer, and a page
count appears only for multi-page records. Each page lasts 12 seconds. Opening `.help` pauses the record's remaining
page time until help closes. A `.leaders` request during help replaces any
pending replay and starts from page one after help closes.
initial map and personal record fetches if necessary. It appears as right-aligned
text in the common-owned upper-right service area, all at once:
```text
Map Record | Round 25
Player 1
Player 2
Player 3
Player 4
Your Best | Round 15
```
Each holder has a separate line. The personal best has its own heading below a
blank row and uses the map heading's brighter colour and font size. A confirmed
empty personal history shows `Your Best | None yet`; a failed lookup shows
`Your Best | Unavailable`. There is no background, replay footer, pagination or
page counter. The whole view lasts 12 seconds. Opening `.help` pauses its
remaining time until help closes. A `.leaders` request during help replaces any
pending replay and starts a fresh 12-second view after help closes.
All holders are included; colour/control codes are removed, and overlong
individual names are abbreviated without splitting UTF-8 characters. Bounded
lines and additional pages preserve every tied holder. The window closes when
the announcement finishes. Both games release their view elements when idle or
changing service kinds. Disconnect or game end cleans up any remaining service
elements.
individual names are abbreviated without splitting UTF-8 characters. The window
closes when the announcement finishes. Both games release their view elements
when idle or changing service kinds, and resize the holder list when a new record
has a different number of holders. Disconnect or game end cleans up any remaining
service elements.
At round start, reminders become more frequent as the current round approaches
the latest usable cached record. The cadence is measured back from that record:
@@ -607,10 +624,10 @@ the latest usable cached record. The cadence is measured back from that record:
For a round 50 record, reminders appear on rounds 30, 35, 40, 42, 44 and
45–49, with milestones at 50 and 51. Each uses the existing record header and
one short proximity or milestone line for six seconds, without holder pages.
one short proximity or milestone line for six seconds, without the holder list.
Milestones describe the round reached; the new result is recorded only after
the match ends. The full join announcement and `.leaders` retain all holders
and their 12-second pages. `.leaders` remains available after the overtake.
in one 12-second view. `.leaders` remains available after the overtake.
Reminders require player readiness and skip players with a pending welcome,
help or record view, or a welcome/manual record request in the last 30 seconds.
@@ -628,10 +645,21 @@ Partial page results never replace the cache. The API has no pagination snapshot
token: changed totals/record rounds reject the refresh, but concurrent renames can
still move entries between pages. Refreshes deduplicate GUIDs.
Personal lookups use the existing public endpoint’s `q` search, scoped to the
resolved map key. Search results can match names or partial GUIDs, so only an
exact string GUID with matching game/map record evidence is accepted. Successful
personal results cache for five minutes per connection; failures cache as
unavailable for 30 seconds. Searches paginate in groups of three and stop
starting further pages after 15 seconds; incomplete searches are unavailable.
A valid empty map history needs no personal request. No backend or plugin update
is needed. Round reminders continue to use only the map cache.
Requests have a 15-second GSC wait limit. The TWS plugins also enforce native
5-second connection and 15-second transfer limits. The leaderboard waits for native
completion before starting another request;
player threads never wait directly on HTTP. Responses at the plugins' 20 KiB
completion before starting another request. Personal lookups share this request
slot with the map worker, with a 15-second queue wait limit. Disconnect, game
end and a newer manual replay cancel the waiting announcement; the level
collector still drains any native request already started. Responses at the plugins' 20 KiB
truncation boundary are rejected. The complete JSON grammar, UTF-8, string escapes
and response shape are checked before consuming data. These guards also reject
HTML/error bodies. Leaderboard still uses body validation; the new native HTTP
@@ -640,14 +668,16 @@ status diagnostics are consumed by telemetry.
Run the portable data/structure checks with `node tools/check_leaderboard.mjs`.
They execute the scripts' data helpers with engine-value shims, not a game VM.
Live checks: join while playing and spectating; die/respawn (no repeat); reconnect
(one new announcement); compare Town/TranZit/Grief; check one holder, ties across
pages, no records, and a slow/unavailable/malformed API. Disconnect or end the game
(one new announcement); compare Town/TranZit/Grief; check one holder, multiple tied holders, no records, and a slow/unavailable/malformed API. Check a personal best
below the map record, a tied holder, a new player, and two players with different
bests. Confirm GUID substring/name matches cannot supply someone else’s record,
and unavailable lookups never claim an empty history. Disconnect or end the game
mid-announcement and confirm messages stop. Verify existing HUD, `.help`, `.join`
and telemetry still work. Open `.help` during a record page and check that the
and telemetry still work. Open `.help` during a record announcement and check that the
record resumes afterwards without overlapping it. Runtime linking and client
readiness require Plutonium.
Try `.leaders` and `.LEADERS` after the announcement ends and during a later
page: each should start one private announcement from the first page. Repeat
Try `.leaders` and `.LEADERS` after the announcement ends and while it is
visible: each should restart one complete private announcement. Repeat
while help is open, then disconnect or end the game during a replay to check
cleanup. Disable the leaderboard module and verify `.leaders` is absent from help.
+24 -18
View File
@@ -16,7 +16,7 @@ Platform quirks and deploy commands: [NOTES.md](NOTES.md).
| [tws_t5_join.gsc](tws_t5_join.gsc) | `.join` lets newcomers spawn before the current round ends |
| [tws_t5_lock.gsc](tws_t5_lock.gsc) | Automatic lobby lock plus majority `.lock` / `.unlock` votes |
| [tws_t5_telemetry.gsc](tws_t5_telemetry.gsc) | Direct HTTP game start, round, heartbeat, and game end reports |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map record on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_leaderboard.gsc](tws_t5_leaderboard.gsc) | Private map and personal records on connection, `.leaders` replay and adaptive round-start reminders |
| [tws_t5_bank.gsc](tws_t5_bank.gsc) | Persistent shared GUID bank and private balance/deposit/withdraw commands |
| [tws_t5_moderation.gsc](tws_t5_moderation.gsc) | Optional controller-managed bans, mutes and live moderation; requires common 1.2.0+ and native plugin 1.3.0+ |
@@ -83,14 +83,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.
@@ -99,15 +101,15 @@ 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
Copy `tws_t5_leaderboard.gsc` and the updated `tws_t5_common.gsc` to
`storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.2.0` requires
common `1.5.0+`; this script update adds no native-plugin or backend dependency
`storage/t5/scripts/sp/`, then restart the map. Leaderboard `1.3.1` requires
common `1.6.1+`; this script update adds no native-plugin or backend dependency
and requires no full server-process restart. Leaderboard requires a
[t5-gsc-utils](https://github.com/alicealys/t5-gsc-utils) build exposing
`http::request`, `jsonParse`, `typeof` and `formatString`. A plugin upgrade
@@ -124,10 +126,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.
## Player commands
@@ -149,7 +155,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) |
@@ -162,9 +168,9 @@ also removes its entries from `.help` after a map restart. 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.
## Dependency graph
@@ -224,7 +230,7 @@ connect listener anywhere else crashes the T5 SP handshake.
- Spawn-handler registry
- Connection-handler registry for optional modules that serve spectator newcomers
- `.help` / `.commands` listener and ordered help registry populated by installed modules
- One service controller per player, with help/record pagination,
- One service controller per player, with help pagination and complete record views,
interruption/resume, request replacement and disconnect/game-end cleanup;
display elements exist only for the active view and are released when idle
- Core element registry with per-element alpha state and a per-player visibility mask
@@ -272,8 +278,8 @@ connect listener anywhere else crashes the T5 SP handshake.
`.help` and the remaining commands still work. Restore the scripts afterward.
3. Verify help closes after 12 seconds. Add custom registered commands to test
the nine-row page limit, counter and repeat-to-restart behavior. Interrupt a record
with help and verify remaining page time resumes; repeat `.leaders` during
help and verify only the latest replay starts from page one.
with help and verify remaining display time resumes; repeat `.leaders` during
help and verify only the latest complete replay starts.
4. Reopen after expiry, die/respawn and reconnect; verify no duplicate text or
leftover elements. Repeat without the HUD module. Follow the complete
[HUD validation guide](../../README.md#hud-validation) before rollout.
+38 -17
View File
@@ -179,18 +179,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);
}
@@ -348,12 +346,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);
@@ -379,15 +386,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)
@@ -405,6 +413,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;
@@ -445,8 +455,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;
@@ -482,13 +494,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)
{
@@ -496,6 +508,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")
@@ -504,9 +525,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_t5_leaderboard.gsc - private map records, round reminders and .leaders replay. */
/* tws_t5_leaderboard.gsc - private map/personal records, round reminders and .leaders replay. */
#include maps\_utility;
#include common_scripts\utility;
@@ -25,7 +25,7 @@ init()
level.tws_lb_cache = undefined;
level.tws_lb_attempt = 0;
level.tws_lb_chase_finished = undefined;
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show the map record again");
scripts\sp\tws_t5_common::register_help_command(".leaders", "Show map and personal records");
scripts\sp\tws_t5_common::register_connect_handler(::lb_welcome);
level thread lb_chat_listener();
level thread lb_worker();
@@ -212,14 +212,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\sp\tws_t5_common::show_record(snapshot);
self scripts\sp\tws_t5_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 getGuid();
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()
@@ -239,6 +338,7 @@ lb_refresh()
return undefined;
snapshot = spawnStruct();
snapshot.key = selection.key;
snapshot.name = lb_display_text(selection.name, 64);
snapshot.round = 0;
snapshot.names = [];
@@ -288,9 +388,18 @@ lb_selection()
return selection;
}
// 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 = [];
@@ -461,28 +570,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;
}
+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;
}
+65 -35
View File
@@ -132,7 +132,7 @@ function engine(game, count = 8, withHud = false) {
c.hud_connect(); c.afk_hud_create();
},
addPlayer() { const player = { sessionstate: 'spectator' }; players.add(player); return player; },
help: () => c.show_help(), record: snapshot => c.show_record(snapshot),
help: () => c.show_help(), record: (snapshot, personal) => c.show_record(snapshot, personal),
reminder: (snapshot, message) => c.show_record_reminder(snapshot, message),
event: name => emit(self, name), end: name => emit(level, name),
get state() { return self.tws_service; },
@@ -295,18 +295,18 @@ for (const game of ['t5', 't6']) {
assert.equal(e.elements.length, count);
assert.equal(e.live.length, 0);
});
check(`${game} help pauses record time and resumes the same page`, () => {
check(`${game} help pauses the complete record and resumes its remaining time`, () => {
const e = engine(game);
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
e.advance(15000);
assert.equal(e.state.record.page, 1);
e.advance(3000);
assert.equal(e.state.record.page, 0);
const remaining = e.state.record.remaining;
e.help();
e.advance(12000);
assert.equal(e.state.help, undefined);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.record.page, 0);
assert.equal(e.state.record.remaining, remaining);
assert.equal(e.state.rows[0].text, 'Four');
assert.deepEqual(Array.from(e.state.rows, row => row.text), ['One', 'Two', 'Three', 'Four']);
assert.equal(e.live.length, 5);
const resumedTexts = e.textCalls;
e.c.service_render(e.state);
@@ -344,7 +344,7 @@ for (const game of ['t5', 't6']) {
assert.equal(request.kind, 'reminder');
assert.equal(request.pages, 1);
assert.equal(request.remaining, 6000);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.record_title.text, 'Map Record | Round 82');
assert.equal(e.state.rows.length, 1);
assert.equal(e.state.rows[0].text, '5 rounds to tie');
assert.equal(e.live.length, 2);
@@ -416,16 +416,14 @@ for (const game of ['t5', 't6']) {
e.record(snapshot(['One', 'Two', 'Three', 'Four']));
assert.equal(e.state.record.kind, 'record');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.state.record.pages, 2);
assert.equal(e.state.record.pages, 1);
assert.equal(e.live.length, 5);
assert(replaced.every(elem => elem.destroyCalls === 1));
e.advance(100);
assert.equal(e.state.record.page, 0, 'old reminder expiry cannot dismiss manual replay');
assert.equal(e.state.record.remaining, 11900);
e.advance(11900);
assert.equal(e.state.record.page, 1);
assert.equal(e.state.rows[0].text, 'Four');
e.advance(12000);
assert.equal(e.state.record, undefined);
assert.equal(e.live.length, 0);
});
check(`${game} invalid reminders do not initialize a service or allocate elements`, () => {
@@ -451,35 +449,67 @@ for (const game of ['t5', 't6']) {
assert.equal(e.elements.length, 2);
assert(e.elements.every(elem => elem.destroyCalls === 1));
});
check(`${game} unavailable, empty and paged records rebuild only when their allocation changes`, () => {
check(`${game} all holders precede a separate personal heading without record pages`, () => {
const e = engine(game);
e.record(snapshot(['Player 1', 'Player 2', 'Player 3', 'Player 4']), 'Your Best | Round 15');
assert.equal(e.state.record.pages, 1);
assert.equal(e.live.length, 6);
assertCounter(e, 1, 1);
assert.deepEqual(e.visible.map(x => x.text), [
'Map Record | Round 82', 'Player 1', 'Player 2', 'Player 3', 'Player 4', 'Your Best | Round 15',
]);
const personal = e.state.personal_title, header = e.state.record_title;
assert.equal(personal.y, 150, 'leave a blank row after all four holders');
assert.deepEqual(personal.color, header.color);
assert.equal(personal.fontScale, header.fontScale);
assert.notDeepEqual(personal.color, e.state.rows[0].color, 'personal best uses the heading style');
e.advance(11000);
assert.equal(e.state.personal_title, personal);
assert.equal(e.visible.length, 6, 'all holders stay visible until dismissal');
e.advance(1000);
assert.equal(e.live.length, 0);
e.record({ round: 0, lines: [] }, 'Your Best | None yet');
assert.equal(e.state.personal_title.text, 'Your Best | None yet');
assert.equal(e.state.personal_title.alpha, 1);
e.record(undefined, 'Your Best | Unavailable');
assert.equal(e.state.personal_title.text, 'Your Best | Unavailable');
assert.equal(e.state.personal_title.alpha, 1);
e.record(snapshot(['A', 'B', 'C']), 'Your Best | Round 10');
e.advance(2000); e.help(); e.advance(12000);
assert.equal(e.state.record.remaining, 10000);
assert.equal(e.state.personal_title.text, 'Your Best | Round 10');
e.record(snapshot(['A', 'B', 'C']));
assert.equal(e.state.personal_title, undefined, 'legacy callers clear the personal heading');
e.event('disconnect');
assert.equal(e.live.length, 0);
});
check(`${game} record allocation follows the full holder count and reuses unchanged layouts`, () => {
const e = engine(game);
e.record(undefined);
assert.equal(e.state.record_title.alpha, 1);
assert.equal(e.state.record_title.text, 'Map record unavailable');
assert.equal(e.live.length, 4);
assert.equal(e.state.record_title.text, 'Map Record unavailable');
assert.equal(e.live.length, 1);
assertCounter(e, 1, 1);
const first = [...e.live];
e.record({ round: 0, lines: [] });
assert.equal(e.state.record_title.text, 'No recorded high round yet');
assert.deepEqual(e.live, first);
e.record(snapshot(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']));
assert(first.every(elem => elem.destroyCalls === 1), 'holder count changes release the old view first');
assert.equal(e.live.length, 9);
assertCounter(e, 1, 1);
e.record(snapshot(['A', 'B', 'C', 'D']));
assert(first.every(elem => elem.destroyCalls === 1), 'pagedness changes release the old view first');
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.live.length, 5);
const paged = [...e.live];
assertCounter(e, 1, 2);
e.advance(12000);
assert.deepEqual(Array.from(e.state.rows, row => row.alpha), [1, 0, 0]);
assert.equal(e.state.rows[0].text, 'D');
assertCounter(e, 2, 2);
assert.deepEqual(e.live, paged, 'page changes reuse the same allocation');
e.record(snapshot(['Single page']));
assert(paged.every(elem => elem.destroyCalls === 1));
const full = [...e.live];
e.advance(1000);
assert(e.state.rows.every(row => row.alpha === 1));
e.record(snapshot(['I', 'J', 'K', 'L', 'M', 'N', 'O', 'P']));
assert.deepEqual(e.live, full);
assert.deepEqual(Array.from(e.state.rows, row => row.text), ['I', 'J', 'K', 'L', 'M', 'N', 'O', 'P']);
e.record(snapshot(['Single holder']));
assert(full.every(elem => elem.destroyCalls === 1));
assertCounter(e, 1, 1);
assert.equal(e.state.rows[0].text, 'Single page');
assert.equal(e.live.length, 4);
assert.equal(e.peakLive, 5, 'new allocations never overlap the released view');
assert.equal(e.state.rows[0].text, 'Single holder');
assert.equal(e.live.length, 2);
assert.equal(e.peakLive, 9, 'new allocations never overlap the released view');
assertTextOnly(e);
});
for (const event of ['end_game', 'game_ended', 'intermission', 'disconnect']) check(`${game} ${event} cleans active and pending pages once`, () => {
@@ -512,11 +542,11 @@ for (const game of ['t5', 't6']) {
e.advance(11000);
assert.equal(e.textCalls, calls);
assert.equal(e.elements.length, count);
assert.equal(e.state.record_title.text, 'Map record | Round 82');
assert.equal(e.state.record_title.text, 'Map Record | Round 82');
assert.equal(e.state.commands[3].command, '.join');
assert.match(e.c.help_description(e.state.commands[3]), /newcomer/);
assert.equal(e.c.help_command_label(e.state.commands[6].command), '.d <amount|all>');
assert.equal(e.elements.length, 4, 'record allocation includes all hidden holder rows');
assert.equal(e.elements.length, 2, 'record allocation includes only the header and actual holders');
assertTextOnly(e);
});
check(`${game} service coordinates clear the watermark and stock lower-right HUD`, () => {
@@ -606,7 +636,7 @@ for (const game of ['t5', 't6']) {
assert.equal(e.textCalls, calls);
e.record(snapshot()); e.advance(1000);
assert(rows.every(row => row.destroyCalls === 1));
assert.equal(e.live.length, 4);
assert.equal(e.live.length, 2);
e.help();
assert.equal(e.live.length, 19);
assert(e.state.command_rows.every(row => !rows.includes(row)));
@@ -677,7 +707,7 @@ check('T5 service transitions preserve actual core element identities, styles, d
assert.equal(e.live.length, 12);
assert.deepEqual(state(), initial);
for (let i = 0; i < 4; i++) {
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(15000); e.help();
e.record(snapshot(['One', 'Two', 'Three', 'Four'])); e.advance(3000); e.help();
assert.equal(e.live.length, 31, '10 core plus two AFK plus 19 help elements; this is not an engine pool-limit assertion');
assert.equal(e.live.filter(elem => elem.archived === false).length, 19);
e.c.toggle_core_hud();
@@ -688,7 +718,7 @@ check('T5 service transitions preserve actual core element identities, styles, d
e.advance(12000);
assert.equal(e.state.rows[0].text, 'Latest');
assert.equal(e.state.record.remaining, 12000);
assert.equal(e.live.length, 16);
assert.equal(e.live.length, 14);
assert(core.every(elem => elem.alpha === 0 && elem.destroyCalls === 0));
e.c.toggle_core_hud();
assert.deepEqual(state(), initial);
+90 -8
View File
@@ -34,8 +34,8 @@ function functions(source) {
let body = protectedSource.slice(headers.lastIndex, end - 1);
// Each GSC function has its own implicit locals, including recursion.
const locals = new Set([...body.matchAll(/(?<![\w.])(\w+)\s*(?:[+*/%-]?=(?!=)|\+\+|--)/g)].map((x) => x[1]));
const coroutine = match[1] === 'lb_round_watcher';
body = body.replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
const coroutine = ['lb_round_watcher', 'lb_get'].includes(match[1]);
body = body.replace(/player getGuid\(/g, 'player.getGuid(').replace(/\btypeof\(/g, 'gscType(').replace(/\.size\b/g, '.length')
.replace(/\bwait\s+([\d.]+);/g, coroutine ? 'yield { milliseconds: $1 * 1000 };' : ';');
if (coroutine)
body = body.replace(/level waittill\(([^;]+)\);/g, 'yield { entity: level, event: $1 };');
@@ -127,7 +127,7 @@ function roundRuntime(game) {
return { ...e, players, attempts, shown, records, notifications, endEvents,
get now() { return e.now; },
readyPlayer(fields = {}) {
const player = { sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
const player = { getGuid: () => undefined, sessionstate: 'playing', tws_lb_last_request: e.now - 30000, ...fields };
players.push(player);
return player;
},
@@ -188,7 +188,7 @@ for (const game of ['t5', 't6']) {
});
check(`${game} common owns the record service window`, () => {
const commonPath = game === 't5' ? 'scripts\\sp\\tws_t5_common' : 'scripts\\zm\\tws_t6_common';
assert(source.includes(`self ${commonPath}::show_record(snapshot);`));
assert(source.includes(`self ${commonPath}::show_record(snapshot, personal);`));
assert(!source.includes('lb_show_hud'));
assert(!source.includes('lb_hud_'));
assert(!source.includes('tws_help_elems'));
@@ -198,7 +198,7 @@ for (const game of ['t5', 't6']) {
});
check(`${game} manual missing/stale requests cancel welcome and reach the service`, () => {
const calls = [];
c.self = { notify: (event) => calls.push(['notify', event]) };
c.self = { getGuid: () => undefined, notify: (event) => calls.push(['notify', event]) };
c.recordService = (snapshot) => calls.push(['record', snapshot]);
const request = all.get('lb_request')
.replace(/\b(?:self|level) endon\([^;]*\);/g, '')
@@ -406,6 +406,88 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_page_valid(value, 0, 1), false);
}
});
check(`${game} HTTP callers serialize and native timeouts retain the slot until collection`, () => {
const e = engine(game), c = e.context;
const started = [], pending = [];
c.httpRequest = (url) => { started.push(url); return {}; };
c.collect = (request, job) => pending.push(job);
c.jsonParse = JSON.parse;
vm.runInContext(e.all.get('lb_get')
.replace(/http::request|httpPost/g, 'httpRequest')
.replace(/level thread lb_collect\(/g, 'collect('), c);
const first = c.lb_get('&first');
assert.equal(first.next().done, false);
const second = c.lb_get('&second');
assert.equal(second.next().done, false);
assert.equal(started.length, 1);
e.setTime(e.now + 15000);
assert.equal(first.next().done, true);
assert.equal(second.next().done, true);
assert.equal(started.length, 1, 'timed-out queue wait must not start another native request');
assert.equal(c.level.tws_lb_pending, pending[0]);
const third = c.lb_get('&third');
assert.equal(third.next().done, false);
Object.assign(pending[0], { done: true, success: true, body: '{}' });
c.level.tws_lb_pending = undefined;
assert.equal(third.next().done, false);
assert.equal(started.length, 2);
Object.assign(pending[1], { done: true, success: true, body: '{"ok":true}' });
c.level.tws_lb_pending = undefined;
const result = third.next();
assert.equal(result.done, true);
assert.equal(result.value.ok, true);
});
check(`${game} personal best finds exact GUID below the top rank across search pages`, () => {
const guid = '00012345678901234567890';
responses.push(page(option, [entry(option, 'prefix' + guid), entry(option, 'other', guid), entry(option, 'third')], 4),
page(option, [entry(option, guid, 'Me', 27)], 4, 3));
assert.equal(c.lb_personal_fetch(option.key, guid), 27);
assert(queries.at(-1).includes(`q=${guid}&limit=3&offset=3`));
assert(queries.at(-1).includes(`map=${encodeURIComponent(option.key)}`));
});
check(`${game} personal absence requires a complete search, failures stay unavailable`, () => {
responses.push(page(option, [entry(option, 'not-me', 'me')], 1));
assert.equal(c.lb_personal_fetch(option.key, 'me'), 0);
responses.push(page(option, [], 0));
assert.equal(c.lb_personal_fetch(option.key, 'me'), 0);
for (const response of [undefined, { ok: false }, page(option, [], 1),
page(option, [entry(option, 'me')], 0),
page(option, [{ ...entry(option, 'me'), guid: 123 }], 1),
page(option, [entry({ ...option, key: 'wrong' }, 'me')], 1),
page(option, [entry({ ...option, game: 'wrong' }, 'me')], 1),
page(option, [{ ...entry(option, 'me'), highestRound: 27 }], 1)]) {
responses.push(response);
assert.equal(c.lb_personal_fetch(option.key, 'me'), undefined);
}
responses.push(page(option, [entry(option, 'a'), entry(option, 'b'), entry(option, 'c')], 4),
page(option, [entry(option, 'me')], 5, 3));
assert.equal(c.lb_personal_fetch(option.key, 'me'), undefined);
});
check(`${game} personal identity, per-player caching, expiry and unavailable retries`, () => {
const player = { guid: '00012345678901234567890', getGuid() { return this.guid; } };
const other = { guid: 'other', getGuid() { return this.guid; } };
const snapshot = { key: option.key, round: 52 };
assert.equal(c.lb_player_guid(player), player.guid);
player.guid = 123;
assert.equal(c.lb_player_guid(player), '123');
player.guid = '00012345678901234567890';
responses.push(page(option, [entry(option, player.guid, 'Me', 27)], 1));
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 27');
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 27');
responses.push(page(option, [], 0));
assert.equal(c.lb_personal_record(other, snapshot), 'Your Best | None yet');
player.tws_lb_personal.saved_at -= 300000;
responses.push(undefined);
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable');
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable');
player.tws_lb_personal.saved_at -= 30000;
responses.push(page(option, [entry(option, player.guid, 'Me', 30)], 1));
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Round 30');
assert.equal(c.lb_personal_record(player, undefined), 'Your Best | Unavailable');
assert.equal(c.lb_personal_record(player, { round: 0 }), 'Your Best | None yet');
player.guid = undefined;
assert.equal(c.lb_personal_record(player, snapshot), 'Your Best | Unavailable');
});
check(`${game} one holder`, () => {
responses.push(discovery(option), page(option, [entry(option, '000123', 'Alice')], 1));
const result = c.lb_refresh();
@@ -455,13 +537,13 @@ for (const game of ['t5', 't6']) {
assert.equal(c.lb_refresh(), undefined);
c.level.tws_lb_override = '';
});
check(`${game} all holders fit bounded service rows`, () => {
check(`${game} each holder gets a separate bounded service row`, () => {
const names = Array.from({ length: 100 }, (_, i) => `Holder ${i}`);
const lines = Array.from(c.lb_holder_lines(names));
assert(lines.every((line) => c.lb_holder_width(line) <= 16));
assert.deepEqual(lines.join(', ').split(', '), names);
assert.deepEqual(Array.from(c.lb_holder_lines([])), []);
assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex, Sam, Jamie']);
assert.deepEqual(Array.from(c.lb_holder_lines(['Alex', 'Sam', 'Jamie'])), ['Alex', 'Sam', 'Jamie']);
});
check(`${game} holder width counts glyphs and clips without breaking UTF-8`, () => {
const samples = ['M'.repeat(64), 'é'.repeat(32), '🧟'.repeat(16), '玩家名字'.repeat(5),
@@ -514,7 +596,7 @@ check('paired data helpers stay identical', () => {
const t5 = functions(readFileSync(new URL('t5/scripts/tws_t5_leaderboard.gsc', root), 'utf8'));
const t6 = functions(readFileSync(new URL('t6/scripts/tws_t6_leaderboard.gsc', root), 'utf8'));
for (const [name, fn] of t5) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready'].includes(name)) {
if (!['lb_selection', 'lb_welcome', 'lb_get', 'lb_log', 'lb_player_ready', 'lb_player_guid'].includes(name)) {
const normalized = t6.get(name).replaceAll('scripts\\zm\\tws_t6_common::', 'scripts\\sp\\tws_t5_common::');
assert.equal(normalized, fn, name);
}
+4 -4
View File
@@ -1,18 +1,18 @@
{
"tws_t5_afk.gsc": "2.1.0",
"tws_t5_bank.gsc": "2.0.3",
"tws_t5_common.gsc": "1.5.0",
"tws_t5_common.gsc": "1.6.1",
"tws_t5_hud.gsc": "1.4.2",
"tws_t5_join.gsc": "1.0.0",
"tws_t5_leaderboard.gsc": "1.2.0",
"tws_t5_leaderboard.gsc": "1.3.1",
"tws_t5_lock.gsc": "1.1.0",
"tws_t5_telemetry.gsc": "1.0.0",
"tws_t6_afk.gsc": "2.1.0",
"tws_t6_bank.gsc": "2.0.3",
"tws_t6_common.gsc": "1.5.0",
"tws_t6_common.gsc": "1.6.1",
"tws_t6_hud.gsc": "1.4.2",
"tws_t6_join.gsc": "1.0.1",
"tws_t6_leaderboard.gsc": "1.2.0",
"tws_t6_leaderboard.gsc": "1.3.1",
"tws_t6_lock.gsc": "1.1.0",
"tws_t6_telemetry.gsc": "1.0.0",
"t5-plugin": "1.4.0",