feat(diagnostics): correlate movement acknowledgements with painted views
This commit is contained in:
@@ -1267,7 +1267,7 @@ confirmation remains pending.
|
|||||||
|
|
||||||
## Milestone 015 — Pre-reader child-friendly game experience
|
## Milestone 015 — Pre-reader child-friendly game experience
|
||||||
|
|
||||||
**Status:** `IN_PROGRESS`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -1363,9 +1363,9 @@ rules.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
The implementation and automated verification are complete; the required
|
The implementation and automated verification are complete. The project owner
|
||||||
representative pre-reader observation or owner-approved evidence deviation
|
approved the required evidence deviation after manual gameplay, as recorded
|
||||||
remains pending.
|
below.
|
||||||
|
|
||||||
- The player screen now uses circle and diamond character shapes instead of
|
- The player screen now uses circle and diamond character shapes instead of
|
||||||
`P1`/`P2`, icon-and-shape ownership markers for keys and exits, dynamic
|
`P1`/`P2`, icon-and-shape ownership markers for keys and exits, dynamic
|
||||||
@@ -1398,7 +1398,273 @@ remains pending.
|
|||||||
`pio run -e esp32-c6-devkitm-1 --target buildfs` passed; the fixed LittleFS
|
`pio run -e esp32-c6-devkitm-1 --target buildfs` passed; the fixed LittleFS
|
||||||
image used 2,031,616 of 2,031,616 bytes and SHA-256
|
image used 2,031,616 of 2,031,616 bytes and SHA-256
|
||||||
`ea6813e603d883a1f6b79a9f9efbd20e7d02f010052210716a89d7e863716415`.
|
`ea6813e603d883a1f6b79a9f9efbd20e7d02f010052210716a89d7e863716415`.
|
||||||
- Acceptance criterion 11 still requires the facilitated representative
|
- On 2026-09-09, the project owner manually exercised the gameplay and
|
||||||
pre-reader observation from `docs/PRE_READER_USABILITY.md`, or a
|
confirmed that all host, firmware, and filesystem checks above pass. The
|
||||||
project-owner-approved documented deviation. Therefore Milestone 015 remains
|
owner also found the separate on-screen joystick imitation uncomfortable and
|
||||||
`IN_PROGRESS`; Milestone 016 has not been started.
|
insufficiently responsive for real play. The owner explicitly approved the
|
||||||
|
criterion-11 evidence deviation: no representative pre-reader observation is
|
||||||
|
required for this milestone. Movement-latency diagnosis is deferred to
|
||||||
|
Milestone 016, and the resulting avatar-centered control refinement is
|
||||||
|
deferred to Milestone 017. Criteria 1--10 have the recorded implementation
|
||||||
|
and automated evidence above; criterion 11 has this documented owner
|
||||||
|
approval. Milestone 015 is therefore `COMPLETE`.
|
||||||
|
|
||||||
|
## Milestone 016 — End-to-end movement latency diagnosis
|
||||||
|
|
||||||
|
**Status:** `IN_PROGRESS`
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Measure and attribute the delay from a player's touch to the corresponding
|
||||||
|
authoritative map update, determine whether the unacceptable delays originate
|
||||||
|
in the local Wi-Fi network, browser input and rendering, ESP firmware, or their
|
||||||
|
interaction under load, and produce evidence that selects the remediation for
|
||||||
|
Milestone 017.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Define separate latency intervals for local pointer feedback, browser command
|
||||||
|
scheduling, WebSocket uplink, ESP receive/parse/game processing, outbound
|
||||||
|
queueing and serialization, WebSocket downlink, complete-view assembly, DOM
|
||||||
|
update, and the next painted frame.
|
||||||
|
- Add bounded diagnostic correlation data and timing counters where existing
|
||||||
|
metrics cannot isolate an interval. Diagnostics must not make browser time
|
||||||
|
authoritative, expose hidden maze state, or create unbounded logs, queues,
|
||||||
|
allocations, or protocol messages.
|
||||||
|
- Measure ICMP and application-level round-trip baselines on the same local
|
||||||
|
Wi-Fi path, including distribution and jitter rather than only an average.
|
||||||
|
- Exercise accepted movement, blocked movement, direction changes, sustained
|
||||||
|
movement, release, and cooldown rejection with two active players, first
|
||||||
|
without observers and then at the six-session limit with four observers.
|
||||||
|
- Compare WebSocket acknowledgement time with the time at which the matching
|
||||||
|
authoritative view has been completely assembled and painted. Record view
|
||||||
|
chunk count, socket buffered bytes, gateway queue depth, send duration, and
|
||||||
|
browser parse, layout, and paint cost for the same test intervals.
|
||||||
|
- Profile the current sequential per-session view broadcast and the browser's
|
||||||
|
complete maze DOM replacement without changing their behavior during the
|
||||||
|
baseline run.
|
||||||
|
- Classify each observed delay by its dominant interval, document reproducible
|
||||||
|
test conditions, and select bounded frontend, protocol, firmware, and/or
|
||||||
|
network remediations for Milestone 017.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
- A documented pointer-to-paint latency model with unambiguous measurement
|
||||||
|
points and command/view correlation.
|
||||||
|
- A repeatable local-device measurement procedure and bounded diagnostic
|
||||||
|
instrumentation for browser and firmware.
|
||||||
|
- Results for baseline and maximum supported session load, including latency
|
||||||
|
percentiles, maxima, network jitter, queue high-water marks, view sizes, and
|
||||||
|
browser rendering costs.
|
||||||
|
- A root-cause report that ranks measured contributors and records the selected
|
||||||
|
remediation scope and performance budget for Milestone 017.
|
||||||
|
- Automated tests for correlation, bounded diagnostics, counter reset/wrap
|
||||||
|
behavior, and preservation of authoritative and hidden-state contracts.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. The test procedure records at least 200 accepted movement samples for both
|
||||||
|
the two-player/no-observer case and the two-player/four-observer case, with
|
||||||
|
`p50`, `p95`, `p99`, and maximum values for every measurable latency
|
||||||
|
interval.
|
||||||
|
2. Local pointer-feedback, command-to-acknowledgement,
|
||||||
|
command-to-complete-view, and command-to-painted-frame latency are measured
|
||||||
|
separately; ESP processing and send durations use the ESP clock, while
|
||||||
|
browser intervals use the browser clock, without assuming synchronized
|
||||||
|
clocks across devices.
|
||||||
|
3. The same report includes at least 100 network round-trip samples with packet
|
||||||
|
loss, minimum, average, maximum, jitter, and the access-point and client
|
||||||
|
conditions needed to reproduce the run.
|
||||||
|
4. Tests cover accepted and rejected commands, blocked movement, direction
|
||||||
|
changes, sustained movement, release, two-player load, and maximum supported
|
||||||
|
observer load without disrupting server authority or exposing hidden state.
|
||||||
|
5. The report determines whether network transport, firmware processing and
|
||||||
|
broadcast, frontend scheduling and rendering, or multiple contributors are
|
||||||
|
responsible for the observed delays, and supports the conclusion with
|
||||||
|
correlated measurements rather than inference alone.
|
||||||
|
6. Diagnostic messages, counters, queues, retries, and allocations are bounded;
|
||||||
|
production behavior remains safe when diagnostics are disabled, a client
|
||||||
|
disconnects, or correlation identifiers wrap.
|
||||||
|
7. All host and browser tests pass, firmware and filesystem images remain
|
||||||
|
within their partitions, and device measurements record exact build identity
|
||||||
|
and relevant commands.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
Implementation and initial device validation are in progress. The following
|
||||||
|
planning-time observations justify the milestone but do not satisfy its
|
||||||
|
controlled measurement criteria:
|
||||||
|
|
||||||
|
- On 2026-09-09, the project owner reported intermittent input delays up to
|
||||||
|
approximately 500 ms and approved active browser and WebSocket measurements
|
||||||
|
against the device at `192.168.1.50`.
|
||||||
|
- A live `/diagnostics` sample with two connections and 24 accepted game
|
||||||
|
commands reported no dropped, failed, malformed, oversized, or rate-limited
|
||||||
|
messages; maximum handler time was 7,800 microseconds and maximum send time
|
||||||
|
was 1,571 microseconds. This low-load sample does not measure end-to-end
|
||||||
|
latency or rule out sequential broadcast delays under session load.
|
||||||
|
- A 20-packet ICMP sample to `192.168.1.50` had zero loss but measured
|
||||||
|
`60.673 / 147.866 / 246.995 ms` minimum/average/maximum round-trip time with
|
||||||
|
`49.350 ms` deviation. The network alone therefore already exceeds the ideal
|
||||||
|
100 ms target in this setup, but correlation is still required before
|
||||||
|
assigning the reported 500 ms delays.
|
||||||
|
- Static inspection found that each accepted movement synchronously broadcasts
|
||||||
|
authoritative view chunks to sessions, while the player browser waits for a
|
||||||
|
complete view and replaces the maze DOM before the updated frame is visible.
|
||||||
|
These are candidate contributors to measure, not yet established root causes.
|
||||||
|
- The browser now stores at most 256 local movement samples and correlates each
|
||||||
|
sent movement request with its acknowledgement's authoritative
|
||||||
|
`stateSequence`, the complete matching view, and the next painted frame.
|
||||||
|
The gateway includes that state sequence in accepted game acknowledgements;
|
||||||
|
no browser timestamp is sent to the ESP and no hidden state is added to a
|
||||||
|
player projection. `docs/M016_LATENCY_PROCEDURE.md` specifies the required
|
||||||
|
controlled runs and interval boundaries.
|
||||||
|
- On 2026-09-09, the focused browser test plus `bash scripts/run_host_tests.sh`
|
||||||
|
passed with 16 browser tests, including bounded latency correlation. Firmware
|
||||||
|
and filesystem builds passed: application usage was 1,027,966 of 2,097,152
|
||||||
|
bytes and the fixed LittleFS image used 2,031,616 of 2,031,616 bytes.
|
||||||
|
- On 2026-09-09, the diagnostics firmware and filesystem image were flashed to
|
||||||
|
the confirmed ESP32-C6FH4 on `/dev/ttyACM0`; the health endpoint reported
|
||||||
|
`serving`. A six-session smoke run recorded 10 accepted commands. Its
|
||||||
|
post-run diagnostics reported a 23,842 microsecond maximum handler duration,
|
||||||
|
a 575,875 microsecond maximum send duration, queue high water of 8/12, and
|
||||||
|
five dropped plus four failed sends. This is not the required 200-sample
|
||||||
|
controlled measurement and cannot identify a root cause.
|
||||||
|
- M016 remains `IN_PROGRESS`. The two 200-accepted-move browser/device runs,
|
||||||
|
100-sample ICMP and application round trips, interval percentiles, and
|
||||||
|
evidence-based remediation selection are still required. Milestone 017 has
|
||||||
|
not been started.
|
||||||
|
- On 2026-09-09, an initial manual run had no drops, send failures, malformed,
|
||||||
|
oversized, or rate-limited messages. Before the run, diagnostics reported
|
||||||
|
8 accepted commands, a 10,779 microsecond maximum handler duration, and a
|
||||||
|
1,591 microsecond maximum send duration. Afterwards, it reported 165
|
||||||
|
accepted commands, a 9,800 microsecond maximum handler duration, a
|
||||||
|
1,572 microsecond maximum send duration, queue high water of 4/12, and no
|
||||||
|
message drop or failure. One browser retained 65 correlated samples: its
|
||||||
|
command-to-acknowledgement values ranged from 19 to 548 ms and its
|
||||||
|
command-to-painted-view values ranged from 30 to 551 ms. An interrupted
|
||||||
|
18-packet ICMP sample had zero loss but measured
|
||||||
|
`35.904 / 121.461 / 219.597 ms` minimum/average/maximum round trip with
|
||||||
|
`51.715 ms` deviation. These partial data point to Wi-Fi transport and/or
|
||||||
|
browser scheduling rather than the measured ESP handler or send path, but
|
||||||
|
they are insufficient for the required percentile-based conclusion.
|
||||||
|
- The attempted six-session application run was invalid because three browser
|
||||||
|
sessions were already active; opening four observers would exceed the
|
||||||
|
six-connection limit. A second attempt then timed out while joining. Close
|
||||||
|
all existing player and observer tabs or restart the device before the clean
|
||||||
|
six-session run.
|
||||||
|
- On 2026-09-09, a later interrupted 57-packet ICMP run still had zero loss
|
||||||
|
but measured `26.403 / 114.039 / 343.368 ms` minimum/average/maximum round
|
||||||
|
trip with `54.637 ms` deviation. A complete 100-request `/health`
|
||||||
|
application-round-trip run measured 7.5 ms minimum, 186.7 ms average,
|
||||||
|
508.0 ms p95, 713.3 ms p99, and 985.4 ms maximum. The M010 six-session
|
||||||
|
script again timed out waiting for the first player `join` response, so it
|
||||||
|
produced no valid application-load samples. These results establish an
|
||||||
|
unacceptable local network tail but do not replace the required clean
|
||||||
|
WebSocket load run or browser interval percentiles.
|
||||||
|
- On 2026-09-09, the project owner approved skipping the M010 six-session
|
||||||
|
script for M016 evidence after repeated timeouts before the first `join`
|
||||||
|
response. The run is recorded as unavailable on the present Wi-Fi path, not
|
||||||
|
as a passing load result. The available ICMP, HTTP, browser, and ESP timing
|
||||||
|
evidence remains the basis for selecting the network remediation; Milestone
|
||||||
|
017 is not started by this deviation.
|
||||||
|
|
||||||
|
## Milestone 017 — Avatar-centered drag steering and latency remediation
|
||||||
|
|
||||||
|
**Status:** `NOT_STARTED`
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Replace the separate on-screen joystick with direct dragging of the centered
|
||||||
|
player avatar, keep the avatar's small local deflection as immediate wordless
|
||||||
|
direction and speed feedback while the authoritative map moves underneath it,
|
||||||
|
and apply the measured Milestone 016 remediations so normal local play feels
|
||||||
|
immediate and never approaches the observed 500 ms delay.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- Make the visible centered player avatar the movement handle, backed by a
|
||||||
|
generous transparent touch target that does not obscure nearby maze cells.
|
||||||
|
- Keep the avatar visually near the viewport center. During a drag, offset it
|
||||||
|
only within a small bounded radius in the drag direction; on release or any
|
||||||
|
reset condition, animate it back to the exact center.
|
||||||
|
- Convert drag angle to exactly one of the four existing cardinal movement
|
||||||
|
intentions with a dead zone and diagonal hysteresis. Map displacement
|
||||||
|
magnitude monotonically to a bounded movement cadence: a small intentional
|
||||||
|
offset moves slowly and a larger offset approaches, but never exceeds, the
|
||||||
|
authoritative 150 ms cooldown.
|
||||||
|
- Move the maze beneath the centered avatar only when server-approved state is
|
||||||
|
received. Local avatar deflection is input feedback and must never predict a
|
||||||
|
successful cell transition, inventory change, visibility change, or result.
|
||||||
|
- Coalesce rapidly changing movement intent and bound in-flight commands so a
|
||||||
|
slow socket, delayed acknowledgement, direction reversal, or release cannot
|
||||||
|
leave stale movement queued in the browser or gateway.
|
||||||
|
- Apply the frontend, protocol, firmware, and/or deployment-network changes
|
||||||
|
selected by Milestone 016. Optimize the measured bottlenecks, including view
|
||||||
|
assembly, rendering, or broadcast only when supported by its evidence.
|
||||||
|
- Preserve keyboard and swipe alternatives, observer read-only behavior,
|
||||||
|
hidden-state protection, Russian accessibility labels, mute and reduced
|
||||||
|
motion behavior, and all pause, disconnect, and focus-loss reset paths.
|
||||||
|
- Update the canonical UI and pre-reader usability documents and validate the
|
||||||
|
interaction on representative mobile touch hardware.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
- Avatar-centered drag control with bounded deflection, direction hysteresis,
|
||||||
|
displacement-based speed, release-to-center animation, and safe reset paths.
|
||||||
|
- Authoritative map-follow behavior and bounded latest-intent command flow.
|
||||||
|
- The evidence-selected latency optimizations across browser, protocol,
|
||||||
|
firmware, and/or documented network setup.
|
||||||
|
- Updated `docs/UI_STYLE.md`, `docs/PRE_READER_USABILITY.md`, Russian semantic
|
||||||
|
labels, deterministic browser tests, host tests, and device test procedure.
|
||||||
|
- Before-and-after latency report using the Milestone 016 measurement method.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. At the canonical `412 x 916` viewport, touching the player avatar or its
|
||||||
|
transparent target starts control without a separate joystick. The target is
|
||||||
|
at least `96 x 96` CSS pixels, the visible avatar remains unobscured, and its
|
||||||
|
center cannot move more than the documented small deflection radius.
|
||||||
|
2. Avatar deflection follows the pointer by the next rendered frame during
|
||||||
|
normal operation, indicates the resolved direction and relative speed
|
||||||
|
without text, and returns to the exact center on release, cancellation,
|
||||||
|
pause, disconnect, blur, hidden-document events, and lost pointer capture.
|
||||||
|
3. The map moves beneath the near-centered avatar only from server-approved
|
||||||
|
projections. The browser never predicts maze position, collision outcome,
|
||||||
|
inventory, visibility, timers, or match results.
|
||||||
|
4. Deflection magnitude maps monotonically to a documented bounded cadence,
|
||||||
|
the dead zone sends no movement, diagonal hysteresis prevents direction
|
||||||
|
flicker, and the fastest cadence cannot bypass the 150 ms server cooldown.
|
||||||
|
5. At most one bounded current movement intention is awaiting completion or
|
||||||
|
replacement per player. Direction changes and release discard stale local
|
||||||
|
intent so delayed network delivery cannot create a long client-side command
|
||||||
|
backlog.
|
||||||
|
6. In the controlled local Wi-Fi test conditions established by Milestone 016,
|
||||||
|
accepted input-to-authoritative-painted-update latency is at most 100 ms at
|
||||||
|
`p95` and at most 150 ms for the maximum sample in both the two-player case
|
||||||
|
and the two-player/four-observer case. No accepted sample reaches 500 ms.
|
||||||
|
7. If the deployment network cannot meet criterion 6 before application work,
|
||||||
|
the documented network remediation is applied and the full test is repeated;
|
||||||
|
application latency is reported separately so network improvements cannot
|
||||||
|
conceal a frontend or firmware regression.
|
||||||
|
8. Browser tests cover pointer capture, touch targeting, bounded offset,
|
||||||
|
dead-zone and speed mapping, diagonal hysteresis, stale-intent coalescing,
|
||||||
|
every reset path, authoritative map motion, keyboard and swipe alternatives,
|
||||||
|
reduced motion, and supported mobile viewports.
|
||||||
|
9. All host tests pass, firmware and web images remain within partition budgets,
|
||||||
|
and the latency instrumentation records no dropped messages, unbounded queue
|
||||||
|
growth, hidden-state disclosure, or observer mutation under maximum supported
|
||||||
|
session load.
|
||||||
|
10. The project owner confirms in representative mobile gameplay that a child
|
||||||
|
can discover movement by dragging the avatar, understand direction and
|
||||||
|
relative speed from its deflection, stop reliably, and experience responsive
|
||||||
|
control, or explicitly approves a documented evidence deviation.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
Not started. This milestone records the project owner's 2026-09-09 decisions:
|
||||||
|
the child drags the centered player icon; the icon deflects slightly as the
|
||||||
|
control handle while the authoritative map moves below it; displacement controls
|
||||||
|
speed; approximately 100 ms is ideal, 150 ms is acceptable, and delays near
|
||||||
|
500 ms are unacceptable. Implementation must wait for Milestone 016 to identify
|
||||||
|
and prioritize the actual latency contributors.
|
||||||
|
|||||||
@@ -767,8 +767,9 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_
|
|||||||
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||||
message->request_id) &&
|
message->request_id) &&
|
||||||
(written = snprintf(prefix, sizeof(prefix),
|
(written = snprintf(prefix, sizeof(prefix),
|
||||||
",\"payload\":{\"commandSequence\":%" PRIu32 "}}", command_sequence)) >=
|
",\"payload\":{\"commandSequence\":%" PRIu32
|
||||||
0 &&
|
",\"stateSequence\":%" PRIu32 "}}",
|
||||||
|
command_sequence, gateway->game.state_sequence)) >= 0 &&
|
||||||
(size_t)written < sizeof(prefix) &&
|
(size_t)written < sizeof(prefix) &&
|
||||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
||||||
enqueue(gateway, session->connection_id, acknowledgement);
|
enqueue(gateway, session->connection_id, acknowledgement);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Milestone 016 movement-latency procedure
|
||||||
|
|
||||||
|
## Measurement model
|
||||||
|
|
||||||
|
Each accepted movement uses one correlation path. Browser times use
|
||||||
|
`performance.now()` and ESP times use its monotonic timer; do not subtract a
|
||||||
|
browser timestamp from an ESP timestamp.
|
||||||
|
|
||||||
|
| Interval | Clock | Measurement point |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pointer feedback | browser | pointer event to next animation frame |
|
||||||
|
| command to acknowledgement | browser | `move` send to matching `ack` |
|
||||||
|
| command to complete view | browser | `move` send to matching assembled view sequence |
|
||||||
|
| command to painted view | browser | `move` send to next frame after matching view render |
|
||||||
|
| handler and send | ESP | existing `/diagnostics` maxima |
|
||||||
|
| network round trip | client | ICMP and WebSocket heartbeat samples |
|
||||||
|
|
||||||
|
The accepted command acknowledgement contains the authoritative `stateSequence`.
|
||||||
|
The browser records a sample only after the complete view with that sequence is
|
||||||
|
rendered and the next animation frame occurs. Samples are local-only, capped at
|
||||||
|
256, and do not alter a command, projection, or server result.
|
||||||
|
|
||||||
|
## Controlled runs
|
||||||
|
|
||||||
|
1. Record the firmware build identity, device address, Wi-Fi access point,
|
||||||
|
client device, browser/version, signal conditions, and viewport.
|
||||||
|
2. Collect 100 `ping -c 100 -q <device>` samples. Record loss, min, average,
|
||||||
|
maximum, and deviation.
|
||||||
|
3. Run 200 accepted moves per player with two players and no observers. Include
|
||||||
|
direction changes, a sustained hold, a release, and one blocked move.
|
||||||
|
4. Repeat with two players and four observers. Capture `/diagnostics` before
|
||||||
|
and after each run.
|
||||||
|
5. For each browser, copy
|
||||||
|
`window.maze32PlayerApp.latencySamples()` after the run. Compute p50, p95,
|
||||||
|
p99, and maximum for all browser intervals. Keep raw samples only for the
|
||||||
|
duration of the report and do not store player names or tokens.
|
||||||
|
6. Compare command-to-acknowledgement with command-to-painted-view, ESP handler
|
||||||
|
and send maxima, queue high-water marks, observer view chunk count, and
|
||||||
|
ICMP/WebSocket round trips. Attribute a delay only when the matching
|
||||||
|
interval is elevated.
|
||||||
|
|
||||||
|
## Safety checks
|
||||||
|
|
||||||
|
- Confirm no dropped, failed, malformed, oversized, or rate-limited message
|
||||||
|
increased unexpectedly outside the deliberate blocked-move case.
|
||||||
|
- Confirm queue high water stays within its published limit and browser sample
|
||||||
|
storage remains at or below 256 entries.
|
||||||
|
- Record blocked-command rejection separately; it does not count as an
|
||||||
|
accepted-move sample.
|
||||||
@@ -43,6 +43,25 @@ test("joystick repeat interval is monotonic and remains slower than cooldown", (
|
|||||||
assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150);
|
assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("latency correlation is bounded and only completes matching authoritative views", () => {
|
||||||
|
let clock = 0;
|
||||||
|
const tracker = new player.LatencyTracker(() => clock);
|
||||||
|
tracker.sent("move-1");
|
||||||
|
clock = 12;
|
||||||
|
tracker.acknowledged("move-1", 41);
|
||||||
|
clock = 16;
|
||||||
|
tracker.painted(40);
|
||||||
|
assert.equal(tracker.snapshot().length, 0);
|
||||||
|
clock = 24;
|
||||||
|
tracker.painted(41);
|
||||||
|
assert.deepEqual(tracker.snapshot(), [{requestId: "move-1", commandToAckMs: 12,
|
||||||
|
commandToPaintMs: 24, ackToPaintMs: 12}]);
|
||||||
|
for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) {
|
||||||
|
tracker.sent(`move-${index + 2}`);
|
||||||
|
}
|
||||||
|
assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT);
|
||||||
|
});
|
||||||
|
|
||||||
test("player map cells enlarge small projections without exceeding a readable bound", () => {
|
test("player map cells enlarge small projections without exceeding a readable bound", () => {
|
||||||
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
|
const sevenBySeven = player.playerCellSize(396, 730, 7, 7);
|
||||||
const fourByFour = player.playerCellSize(396, 730, 4, 4);
|
const fourByFour = player.playerCellSize(396, 730, 4, 4);
|
||||||
|
|||||||
+44
-4
@@ -12,6 +12,7 @@
|
|||||||
const JOYSTICK_HYSTERESIS = .18;
|
const JOYSTICK_HYSTERESIS = .18;
|
||||||
const JOYSTICK_SLOW_REPEAT_MS = 360;
|
const JOYSTICK_SLOW_REPEAT_MS = 360;
|
||||||
const JOYSTICK_FAST_REPEAT_MS = 170;
|
const JOYSTICK_FAST_REPEAT_MS = 170;
|
||||||
|
const LATENCY_SAMPLE_LIMIT = 256;
|
||||||
const ONBOARDING_STEPS = Object.freeze([
|
const ONBOARDING_STEPS = Object.freeze([
|
||||||
Object.freeze({icon: "hand", title: "Потяни кружок", text: "Кружок ведёт героя по лабиринту."}),
|
Object.freeze({icon: "hand", title: "Потяни кружок", text: "Кружок ведёт героя по лабиринту."}),
|
||||||
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
Object.freeze({icon: "motion", title: "Отпусти — герой ждёт", text: "Рука остановилась — шаги тоже."}),
|
||||||
@@ -145,6 +146,40 @@
|
|||||||
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
|
return currentStep >= ONBOARDING_STEPS.length - 1 ? null : currentStep + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LatencyTracker {
|
||||||
|
constructor(now) {
|
||||||
|
this.now = typeof now === "function" ? now : () => root.performance.now();
|
||||||
|
this.pending = new Map();
|
||||||
|
this.samples = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
sent(requestId) {
|
||||||
|
if (!requestId) return;
|
||||||
|
this.pending.set(requestId, {sent: this.now(), acknowledged: null, stateSequence: null});
|
||||||
|
if (this.pending.size > LATENCY_SAMPLE_LIMIT) this.pending.delete(this.pending.keys().next().value);
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledged(requestId, stateSequence) {
|
||||||
|
const sample = this.pending.get(requestId);
|
||||||
|
if (!sample) return;
|
||||||
|
sample.acknowledged = this.now();
|
||||||
|
sample.stateSequence = Number.isInteger(stateSequence) ? stateSequence : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
painted(stateSequence) {
|
||||||
|
for (const [requestId, sample] of this.pending) {
|
||||||
|
if (sample.acknowledged == null || sample.stateSequence !== stateSequence) continue;
|
||||||
|
const painted = this.now();
|
||||||
|
this.samples.push({requestId, commandToAckMs: sample.acknowledged - sample.sent,
|
||||||
|
commandToPaintMs: painted - sample.sent, ackToPaintMs: painted - sample.acknowledged});
|
||||||
|
if (this.samples.length > LATENCY_SAMPLE_LIMIT) this.samples.shift();
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() { return this.samples.map(sample => Object.assign({}, sample)); }
|
||||||
|
}
|
||||||
|
|
||||||
function createEnvelope(type, sequence, requestId, payload) {
|
function createEnvelope(type, sequence, requestId, payload) {
|
||||||
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
return {v: 1, type, seq: sequence, requestId, payload: payload || {}};
|
||||||
}
|
}
|
||||||
@@ -179,6 +214,7 @@
|
|||||||
}
|
}
|
||||||
const first = this.pending.parts[0];
|
const first = this.pending.parts[0];
|
||||||
const snapshot = Object.assign({}, first, {
|
const snapshot = Object.assign({}, first, {
|
||||||
|
stateSequence: message.seq,
|
||||||
cells: this.pending.parts.flatMap(part => part.cells),
|
cells: this.pending.parts.flatMap(part => part.cells),
|
||||||
objects: this.pending.parts.flatMap(part => part.objects)
|
objects: this.pending.parts.flatMap(part => part.objects)
|
||||||
});
|
});
|
||||||
@@ -208,7 +244,7 @@
|
|||||||
|
|
||||||
function boot(documentObject, windowObject) {
|
function boot(documentObject, windowObject) {
|
||||||
const byId = id => documentObject.getElementById(id);
|
const byId = id => documentObject.getElementById(id);
|
||||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, joystick: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), latency: new LatencyTracker(() => windowObject.performance.now()), audio: null, feedbackReady: false, muted: false, reducedMotion: false, onboardingSeen: false, onboardingStep: 0, joystick: {pointerId: null, direction: null, repeatTimer: null, repeatMs: null}, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||||
|
|
||||||
function showNotice(text, kind) {
|
function showNotice(text, kind) {
|
||||||
const element = byId("notice");
|
const element = byId("notice");
|
||||||
@@ -287,6 +323,7 @@
|
|||||||
const requestId = `${type}-${sequence}`;
|
const requestId = `${type}-${sequence}`;
|
||||||
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
state.socket.send(JSON.stringify(createEnvelope(type, sequence, requestId, payload)));
|
||||||
setPending(requestId, element);
|
setPending(requestId, element);
|
||||||
|
if (type === "move") state.latency.sent(requestId);
|
||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +448,9 @@
|
|||||||
byId("compass-stat").dataset.direction = hint;
|
byId("compass-stat").dataset.direction = hint;
|
||||||
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
byId("compass-arrow").style.transform = `rotate(${HINT_ANGLES[hint]}deg)`;
|
||||||
renderMap(snapshot);
|
renderMap(snapshot);
|
||||||
|
if (Number.isInteger(snapshot.stateSequence)) {
|
||||||
|
windowObject.requestAnimationFrame(() => state.latency.painted(snapshot.stateSequence));
|
||||||
|
}
|
||||||
if (snapshot.phase === "paused") { resetJoystick(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
|
if (snapshot.phase === "paused") { resetJoystick(); showModal("Пауза", "Один герой потерялся между Wi‑Fi пакетами. Ждём возвращения.", "ПЕРЕРЫВ", false); }
|
||||||
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
else if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||||
else {
|
else {
|
||||||
@@ -551,7 +591,7 @@
|
|||||||
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||||
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetJoystick(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
} else if (message.type === "reset") { state.snapshot = null; state.viewSequence = -1; state.assembler = new SnapshotAssembler(); resetJoystick(); hideModal(); setScreen("lobby"); showNotice("Лабиринт пересажен. Можно начинать новую гонку.", "success");
|
||||||
} else if (message.type === "ack") { feedback(true);
|
} else if (message.type === "ack") { state.latency.acknowledged(message.requestId, message.payload && message.payload.stateSequence); feedback(true);
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
|
||||||
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
|
if (message.payload.code === "blocked") { const bump = byId("wall-bump"); bump.textContent = localError("blocked"); bump.classList.remove("show"); void bump.offsetWidth; bump.classList.add("show"); }
|
||||||
@@ -689,10 +729,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
connect();
|
connect();
|
||||||
return {state, handleMessage, move, resetJoystick};
|
return {state, handleMessage, move, resetJoystick, latencySamples: () => state.latency.snapshot()};
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, playerCellSize, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
|
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, ONBOARDING_STEPS, LATENCY_SAMPLE_LIMIT, JOYSTICK_DEAD_ZONE, JOYSTICK_HYSTERESIS, JOYSTICK_SLOW_REPEAT_MS, JOYSTICK_FAST_REPEAT_MS, LatencyTracker, directionFromKey, directionFromSwipe, joystickDirection, joystickRepeatMs, joystickVector, playerCellSize, nextOnboardingStep, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
|
||||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||||
root.Maze32Player = api;
|
root.Maze32Player = api;
|
||||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||||
|
|||||||
Reference in New Issue
Block a user