From b6d484dbc9728dce5f9d3d19201492bf911d4b00 Mon Sep 17 00:00:00 2001 From: sasa Date: Sun, 13 Sep 2026 22:53:18 +0300 Subject: [PATCH] feat(session): add explicit leave and immediate slot release --- ARCHITECTURE.md | 7 + PLANS.md | 206 +++++++++++++++++- .../maze32_lobby/include/maze32_lobby.h | 2 + .../maze32_lobby/include/maze32_protocol.h | 1 + components/maze32_lobby/maze32_lobby.c | 24 ++ components/maze32_lobby/maze32_protocol.c | 3 + components/maze32_web/maze32_gateway.c | 51 ++++- data/index.html.gz | Bin 3337 -> 3511 bytes docs/M016_LATENCY_PROCEDURE.md | 5 + docs/PLAYER_UI.md | 6 + docs/PROTOCOL.md | 8 +- docs/UI_STYLE.md | 9 + test/web/test_player_app.js | 39 +++- web/index.html | 4 + web/player.css | 8 + web/player.js | 166 +++++++++++++- 16 files changed, 526 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c107ad8..3924481 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -351,6 +351,13 @@ visible edge and an adjacent server-approved self-cell move across that exact edge; it is bounded, replaceable by a newer projection, and never delays the received state. +The browser may keep one low-priority heartbeat in flight after a session is +established. It measures complete round trip with its own monotonic clock; the +matching acknowledgement contains only a bounded ESP-monotonic handling +interval. The displayed communication remainder is `max(0, round trip - ESP +handling)` and is never represented as a synchronized-clock measurement or +pure Wi-Fi diagnosis. + Player projections are translated only within their received cells so the authoritative `self` cell aligns with the responsive local focal point. That point is centered horizontally and 30% down the usable portrait map region; diff --git a/PLANS.md b/PLANS.md index bcda886..1481215 100644 --- a/PLANS.md +++ b/PLANS.md @@ -3265,7 +3265,7 @@ indicator. ## Milestone 029 — Low-overhead live connection quality -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -3412,5 +3412,205 @@ resources. measured ESP handling interval and an honestly labelled communication remainder, while keeping the displayed headline value equal to the complete browser-to-ESP application round trip. -- Implementation and verification have not started, as requested by the - project owner. +- On 2026-09-13, implementation began with a single in-flight browser + heartbeat sampled no more often than every five seconds, a five-sample + median, 15-second stale timeout, and explicit unknown, stale, and offline + indicator states. Heartbeat acknowledgements now include bounded ESP-only + `handlingMs`, and the browser derives the non-negative `Связь` remainder. +- On 2026-09-13, `node --test test/web/test_player_app.js + test/web/test_hedge_svg.js` passed all 33 tests, including bounded + heartbeat correlation, malformed-field rejection, smoothing, stale state, + indicator copy, and compact header placement. +- On 2026-09-13, `bash scripts/run_host_tests.sh` passed. `python3 + scripts/verify_web_assets.py` passed with a deterministic 32,503 / + 1,572,864 byte compressed payload. `/home/sasa/.platformio/penv/bin/pio + run` passed with firmware at 1,029,808 / 2,097,152 bytes and + `/home/sasa/.platformio/penv/bin/pio run --target buildfs` passed with a + 2,031,616 / 2,031,616 byte LittleFS image. +- Physical-device latency measurements and mobile and desktop visual + acceptance were pending until the project-owner check below. +- On 2026-09-13, the project owner manually checked the quality indicator and + accepted `ESP: 0 мс` as the correct rounded display for the measured + sub-millisecond heartbeat handling interval. The project owner found no UI + issue and accepted the indicator on the available application screens. + +## Milestone 030 — Explicit session exit and slot release + +**Status:** `IN_PROGRESS` + +### Objective + +Let an authenticated player or observer deliberately leave through the +top-right settings menu, revoke only that browser's session, immediately free +its slot, and return it to the name-entry screen with its previous display name +prefilled. A player leaving an unfinished match must authoritatively cancel +that match and return every remaining participant to the lobby, while an +observer departure or a lobby departure must not reset gameplay. + +### Scope + +- Do not start implementation until every preceding milestone is `COMPLETE`. + Milestone 029 is `COMPLETE` when this plan is created; this milestone remains + `NOT_STARTED` because implementation was not requested. +- Add one clearly labelled Russian `Выйти из игры` action to the existing + gear-triggered settings menu. Show it only while the browser owns an + authenticated player or observer session; do not show it on the unauthenticated + welcome and name-entry screen. +- Require a modal confirmation before sending an exit command. For a player in + countdown, active, finish-pending, or paused state, clearly warn that the + current match will end for everyone and the player's slot will be freed. For + a player in the lobby or an observer in any phase, explain that only this + browser's place will be released. +- Treat opening or cancelling the confirmation as browser-local UI only. While + it is open, suppress input from that browser without pausing or otherwise + mutating authoritative state; cancelling must restore the previous screen, + focus, controls, and session unchanged. +- Introduce a versioned authenticated explicit-leave protocol command and a + correlated success response. It must be distinct from an unexpected socket + disconnect, page reload, or transient connection loss, which retain the + existing 60-second player reconnection contract. +- On an accepted player leave during countdown, active, finish-pending, or + paused state, stop all match timers, clear the current authoritative match, + return the lobby to its waiting state, retain every other connected session + and role, revoke the leaving session, free its player slot immediately, and + broadcast the cancellation and fresh lobby state to the remaining players + and observers. +- On an accepted player leave while already in the waiting lobby or after a + completed result, revoke only that player's session and free its slot. Do not + reset an unfinished match because none is active in those phases. +- On an accepted observer leave in any phase, revoke only that observer's + session and free its observer slot. Never pause, reset, finish, restart, or + otherwise mutate the match because an observer leaves. +- Preserve the roles and sessions of all participants who did not leave. If + Player 1 leaves, Player 2 remains in its existing slot and a later player may + claim the newly free Player 1 slot and become the lobby owner under the + existing ownership rule. +- Invalidate the departed session token on the ESP before confirming success. + The revoked token must never restore its former role, slot, position, + inventory, or reconnect reservation, and a duplicate or replayed leave must + be harmless. +- After confirmed success, remove the session token from browser-local storage, + clear all session, match, input, timer, heartbeat, animation, and pending + command state, and show the welcome/name-entry screen without requiring a + page reload. +- Store the last successfully used display name separately from the temporary + session token in a bounded browser-local value. Prefill it on return to the + name-entry screen, but still apply normal name validation and duplicate-name + rejection if the user tries to join again. +- Do not claim that the slot was released before the ESP acknowledges the + explicit leave. If the command cannot be delivered or confirmed, show a + concise Russian error and retain honest reconnect behavior rather than + silently turning a network failure into a successful exit. +- Bound command size, correlation state, acknowledgement waiting, and cleanup. + The leave path must not disclose hidden maze state, permit one participant to + revoke another session, bypass observer read-only gameplay rules, or create + a new unbounded queue or retry loop. +- Preserve the compact header, live connection-quality indicator, Wi-Fi and + preference actions, responsive layouts, offline operation, Russian + player-facing copy, and firmware and LittleFS budgets. + +### Deliverables + +- Updated explicit-leave, match-cancellation, slot-release, session-revocation, + and reconnect contracts in `MVP.md`, `ARCHITECTURE.md`, `docs/PROTOCOL.md`, + `docs/UI_STYLE.md`, and `docs/PLAYER_UI.md`. +- A bounded authenticated leave command and response implemented across the + protocol parser, lobby, authoritative game lifecycle, gateway, and browser. +- A settings-menu exit action with role- and phase-appropriate confirmation, + failure handling, focus restoration, and successful return to the + name-entry screen. +- Separate bounded persistence for the last player display name and complete + cleanup of the revoked temporary session. +- Deterministic protocol, lobby, gateway, game-lifecycle, browser, + accessibility, race, replay, reconnect, responsive-layout, and regression + tests. +- Host, browser, web-asset, firmware, LittleFS, maximum-session, and + physical-device verification evidence. + +### Acceptance criteria + +1. An authenticated player or observer sees exactly one `Выйти из игры` item + in the gear menu on every applicable screen. An unauthenticated browser sees + no exit item, and primary game controls remain outside the menu. +2. Selecting the item opens a keyboard- and touch-accessible confirmation + modal and sends no protocol command until explicit confirmation. Cancelling, + pressing Escape, or activating the modal's cancel action leaves the session + and authoritative state unchanged and safely restores focus. +3. A player confirmation during countdown, active, finish-pending, or paused + state explicitly says that the match will end for everyone and the slot will + be freed. Confirmation produces exactly one authoritative cancellation, + stops every relevant timer, clears the current match, and returns all + remaining connected participants to a fresh waiting lobby. +4. The leaving player's session is removed and its slot becomes available in + the same accepted server transition. Every other player's and observer's + session, role, and connection remain valid, and the resulting lobby snapshot + consistently shows the newly free slot. +5. A player leaving the waiting lobby or a completed result releases only that + player's session and slot. It does not disconnect other participants or + perform an additional match reset. +6. An observer leaving during any phase releases only that observer session and + observer slot. Match phase, players, timers, maze, objects, inventory, and + results remain byte-for-byte unchanged except for the expected lobby + connection revision or participant count. +7. The explicit leave command authenticates the sending session and cannot + name or revoke another token or slot. Duplicate, stale, reordered, malformed, + unauthorized, and replayed requests cause no second cancellation, no extra + slot release, and no unrelated state mutation. +8. An accepted explicit leave never starts the 60-second reconnect reservation. + A normal page reload, accidental socket loss, or unconfirmed exit continues + to follow the existing reconnect and pause rules without behavioral change. +9. Only after the correlated success response, the departing browser deletes + its temporary session token, clears all role and gameplay state, stops all + input, animation, heartbeat, reconnect, and pending-command work, and shows + the welcome/name-entry screen without a full reload or stale map flash. +10. The last successfully used display name survives separately from the + revoked token and is prefilled in the name field after exit and on a later + visit. It is bounded to the existing name limit, contains no token data, + and receives the normal validation and uniqueness checks when reused. +11. If the leave request cannot be sent, times out, loses its socket, or receives + a rejection, the browser does not report a successful release. It presents + concise Russian recovery information, retains bounded state, and neither + retries indefinitely nor corrupts a later reconnect or successful leave. +12. If Player 1 leaves, Player 2 remains in Player 2's slot and a new player can + immediately claim the free Player 1 slot, becoming the lobby owner and + gaining start authority only through the existing server rule. Equivalent + behavior applies when Player 2 leaves. +13. The exit item and confirmation fit supported narrow portrait, + compact-landscape, tablet, and desktop layouts, respect safe areas, avoid + page and map overflow, and remain distinguishable without relying only on + color, sound, vibration, or animation. +14. Automated tests cover every role and phase, confirmation and cancellation, + authoritative match cancellation, retained participants, immediate slot + reuse, token revocation, remembered-name prefill, disconnect races, + idempotence, authorization, cleanup, accessibility, and responsive layout. +15. All host and browser tests pass; compressed-web-asset validation passes; + the payload remains within the 1.5 MiB web budget and LittleFS partition; + and firmware remains within the factory partition. +16. Physical-device testing confirms that a player can cancel an unfinished + match and immediately release its slot, an observer can leave without + affecting play, the remaining browsers receive the correct lobby state, + and the departing browser returns to a prefilled name-entry screen without + stale controls, delayed input, or reconnect side effects. +17. The project owner confirms on representative mobile and desktop browsers + that the exit action is easy to find but difficult to trigger accidentally, + its consequences are clear, and all role-specific outcomes match the + intended behavior, or explicitly approves a documented evidence deviation. + +### Evidence + +- On 2026-09-13, planning inspection confirmed that unexpected player socket + loss currently pauses an active match and reserves the player session for a + 60-second reconnect, while observer disconnect does not affect gameplay. + Therefore immediate voluntary slot release requires an explicit authenticated + lifecycle command rather than client-only token deletion or socket closure. +- On 2026-09-13, the project owner required a settings-menu exit action with a + confirmation modal, complete session-token revocation, immediate slot + release, and return to the name-entry screen with the previous player name + prefilled. +- The project owner also required an unfinished match to be cancelled for all + participants when a player leaves, while a lobby player or observer departure + must affect only the departing session. +- On 2026-09-13, implementation began with an authenticated `leave` command, + immediate server-side session revocation, player-match cancellation, and a + browser confirmation flow that clears the temporary token only after the + correlated acknowledgement. diff --git a/components/maze32_lobby/include/maze32_lobby.h b/components/maze32_lobby/include/maze32_lobby.h index 633047f..29c7fd1 100644 --- a/components/maze32_lobby/include/maze32_lobby.h +++ b/components/maze32_lobby/include/maze32_lobby.h @@ -66,6 +66,8 @@ maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int conn maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id, const char* token, maze32_session_t** session); void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id); +int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role, + unsigned int* player_slot); maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms); int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms); void maze32_lobby_reset_match(maze32_lobby_t* lobby); diff --git a/components/maze32_lobby/include/maze32_protocol.h b/components/maze32_lobby/include/maze32_protocol.h index 7eba48b..c88ea74 100644 --- a/components/maze32_lobby/include/maze32_protocol.h +++ b/components/maze32_lobby/include/maze32_protocol.h @@ -22,6 +22,7 @@ typedef enum { MAZE32_MESSAGE_MOVE, MAZE32_MESSAGE_DROP_KEY, MAZE32_MESSAGE_RESTART, + MAZE32_MESSAGE_LEAVE, MAZE32_MESSAGE_HEARTBEAT, } maze32_message_type_t; diff --git a/components/maze32_lobby/maze32_lobby.c b/components/maze32_lobby/maze32_lobby.c index e39ffe5..9331da6 100644 --- a/components/maze32_lobby/maze32_lobby.c +++ b/components/maze32_lobby/maze32_lobby.c @@ -167,6 +167,30 @@ void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) { } } +int maze32_lobby_leave(maze32_lobby_t* lobby, int connection_id, maze32_role_t* role, + unsigned int* player_slot) { + if (lobby == NULL) { + return 0; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + maze32_session_t* session = &lobby->sessions[index]; + if (session->role != MAZE32_ROLE_NONE && session->connected && + session->connection_id == connection_id) { + if (role != NULL) { + *role = session->role; + } + if (player_slot != NULL) { + *player_slot = session->player_slot; + } + memset(session, 0, sizeof(*session)); + session->connection_id = -1; + lobby->revision++; + return 1; + } + } + return 0; +} + maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms) { if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) { diff --git a/components/maze32_lobby/maze32_protocol.c b/components/maze32_lobby/maze32_protocol.c index 39f5e45..db2bd82 100644 --- a/components/maze32_lobby/maze32_protocol.c +++ b/components/maze32_lobby/maze32_protocol.c @@ -193,6 +193,9 @@ static maze32_message_type_t message_type(const char* value) { if (strcmp(value, "restart") == 0) { return MAZE32_MESSAGE_RESTART; } + if (strcmp(value, "leave") == 0) { + return MAZE32_MESSAGE_LEAVE; + } if (strcmp(value, "heartbeat") == 0) { return MAZE32_MESSAGE_HEARTBEAT; } diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index 9b07eb4..f31848f 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -948,6 +948,40 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ "Сначала выберите роль или восстановите сессию"); return; } + if (message->type == MAZE32_MESSAGE_LEAVE) { + maze32_role_t leaving_role = session->role; + int cancel_match = leaving_role == MAZE32_ROLE_PLAYER && + gateway->lobby.phase != MAZE32_LOBBY_WAITING && + gateway->game.phase != MAZE32_GAME_PHASE_FINISHED; + char acknowledgement[192] = ""; + size_t length = 0U; + char prefix[96]; + int written = snprintf(prefix, sizeof(prefix), + "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":", + next_server_sequence(gateway)); + if (written >= 0 && (size_t)written < sizeof(prefix) && + append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) && + append_json_string(acknowledgement, sizeof(acknowledgement), &length, + message->request_id) && + append_text(acknowledgement, sizeof(acknowledgement), &length, + ",\"payload\":{\"command\":\"leave\"}}")) { + enqueue(gateway, socket_fd, acknowledgement); + } + if (cancel_match) { + esp_timer_stop(gateway->countdown_timer); + esp_timer_stop(gateway->reconnect_timer); + esp_timer_stop(gateway->finish_timer); + initialize_game(gateway); + maze32_lobby_reset_match(&gateway->lobby); + } + maze32_lobby_leave(&gateway->lobby, socket_fd, NULL, NULL); + if (cancel_match) { + broadcast_match_event(gateway, "reset", "{}"); + } + broadcast_snapshot(gateway); + drain_queue(gateway); + return; + } if (message->type == MAZE32_MESSAGE_START) { if (session->role == MAZE32_ROLE_OBSERVER) { ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd); @@ -977,18 +1011,29 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_ return; } if (message->type == MAZE32_MESSAGE_HEARTBEAT) { + int64_t heartbeat_started_us = esp_timer_get_time(); char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; size_t length = 0U; char prefix[128]; + char handling_text[32]; int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32, next_server_sequence(gateway)); - if (written >= 0 && (size_t)written < sizeof(prefix) && + uint64_t handling_ms = (uint64_t)(esp_timer_get_time() - heartbeat_started_us) / 1000U; + if (handling_ms > 1000U) { + handling_ms = 1000U; + } + int handling_written = + snprintf(handling_text, sizeof(handling_text), "%" PRIu64, handling_ms); + if (written >= 0 && (size_t)written < sizeof(prefix) && handling_written >= 0 && + (size_t)handling_written < sizeof(handling_text) && append_text(response, sizeof(response), &length, prefix) && append_text(response, sizeof(response), &length, ",\"requestId\":") && append_json_string(response, sizeof(response), &length, message->request_id) && append_text(response, sizeof(response), &length, - ",\"payload\":{\"command\":\"heartbeat\"}}")) { - enqueue(gateway, socket_fd, response); + ",\"payload\":{\"command\":\"heartbeat\",\"handlingMs\":") && + append_text(response, sizeof(response), &length, handling_text) && + append_text(response, sizeof(response), &length, "}}")) { + enqueue_with_policy(gateway, socket_fd, response, 1); } } } diff --git a/data/index.html.gz b/data/index.html.gz index 0093968f9ccb47132351ca51c5f8d866d3d5fb9d..43a0a42ba5292f62b3f4338105d55e2955820dd1 100644 GIT binary patch literal 3511 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 literal 3337 zcmV+k4fgUMiwFP!00002|Ls~^Z`{Tee$TI9Hjfo0OAeZ}SS=8s=}S?xKpq10ZI&GE zGUAdf$(1ZW$rcwug?8M^^4sho7KCrCrh^NeQhAd8ay4V?~ zOZKOo@jKCtTriZQ*ptN8ENoKa91tKf3dqJD<$Yi`l7{`n)${_xarg>vH|2m0e`#*Eqd?SC5KZMc!?wpv8ANpRfY{lGP>?G-$&yykNAiyx@ON*Vk$Y*Ojo^_H0 zl`$u@(d~!snqZN6f#tc29Tr8z28_Ahiskq$NftXJo+NC*?U;Ffhvo%g3X14haR{OV zL6(k_j>Te+*}k^|Ls95^DVIYm4M&~>;>Tkc8hWnFgI;$IC`ek!504eOG>rNzmVCfl zre5@87Py5>iXQ8u&_ZJ~R(6){TG>H1&33c>>VY-)Y}#h)+N|5XT6=n}-~kJB_PsC%%aErx_NVED*# zVyEF?;KPQX-lE>Fn>z> zf##a8gBcaiO67AAf);AjlpIe3ZsS{cA!!K}2NIblBXi6x%P z3)P$48{&&0!GS5k4|SH6ZWB;TFzD5%0B7P=F;lkyMbiZ=@ccw@A|KR7zlEg^VX0~U zEPo9Ceg?Zev9d}2P5vC!I6<>M7mlBu&&sWD-t>YXIN_)&_c(3d^e6^D*LGDpYPOy2 z=Ns9{Z!Mq;?3voX9qCXw(JhF-B;)@{>Ek^xG30(!WkFHHJvagXI)d#b>jmfq2r0Is zMwZ=#;;Oggl~5dqD_(+XFs6< zEaFiRua1=l?sK=lR!*P@(rLoNA&*0wVHj-kRo%WL?;iI7WRjtUf9x=*Vq43^bT!l_ zqH8xo0D^lJ0(8XFn1+?OUucA$3gKX8q~`H8-jBmo39z{5)BGuc)fV{~{5-)(LgC~P zjE;Vv0|xJQ=OS&z;k==5sm|=Rh|>9{1-=93+wI3f6+H~-;KdVw$Snv{$6(Qbm5pb_e7x^aS0LNe`yV*OD5*)$MKC+a(o3*kXTo0b%uZ7l2Q%=!n^M^>_0b&RU z0j%IB69|+b&>k)b{2dWD8z|_xScJYKtU1-vwqh+=P45cn$eAV(ps)Yjhy z!Orq6S{XKIiUwD%t-DSQIWZU+=>pLS`w5R%8Yu06GS3OYCQ=R&swAi#2pp}!rk4B7 zNld#M;{34RUo!yC9I1tIiE?6AJH|_$Lhxv2J z3N;6!il8AO)Z`Uw3}VmK4IMzHf3L%_a9fAP zt|fnCaL`?Pz7kB>3Z&R^C8>M`vQ80B4am4{Dk3ud1oM9n5GXGp; z46r68QYvSVdDRI8{RTzs;Ccz`Lzoe1{9{=$+`U|Xxd8v> zwRl@m=k22((Kn)>Inq?bDpl=MnniYi+2dJb7}R`foEXrdIvR~x5mY7t#5tLf6i-cH zLp?XqBWfgn$xi&Rt;lFC32uxu1r-T0KY%42gFo#{!;lVkKv^%Ss){ zCViO2m=)e!Ofmn;eZute0@1QbJV_Vi`<@W?u?%wUQnsdmBNo{SV6Cr^qg}C;s|9Dq!Ez9;2DWTOmyBx_-faypW^S#oDC%>SSrcB8+2v1Q;7mdVR=ld# z#!ZtQd5AjLCOW~OMByvnY-!L|x8r^x(EXyN?u`bOEqFoY1{SXvN-5qv5GB{t+?;H; zb4`sW09ZA71@Y=wLyJyv^3Qy$tXm~+ioI;!(uQbY4_V;)+#s!nTH-)u0U4ffKW~+c z)>m@9SQdd-E!O0~YH!Wrw!X!y;LVU#jAR|BJ+{fJ&PWp02Q<9qK$`Gfi|oRo7cfs(x`06v+6e zKWOosyxXAMvW2Ds16C-|jhI`J>5>)NIXs|ED#UY;&fP{ zAX?zEm4Xce?Bo&rHUM6~q2>;j=6ILDIN3sc2TR|Twfm&zHx;_cvKL)R z_br8zi?SipaF|AAM{QbjpIR%FZ&5cF%@MtHt_`;Jj*E%y?c;Jv82b991u?C$k^B#k zU-n+kWJ~xNwlHMNXUqEck$LixKc-i*^eOwyRsJAF#FF-DaYe++zZQox)F(OB`pnJX zQ8cqCacaEKQRz^Z9o_fPD71_&Z)1}yng=n9Q}9~-)4-cJVSEp~mF>}H&K9oc%>1Cz z$0rZi6Q{3G_y06Cx1X$3j)slGY{ z^d~PG!Ge~LdBW4$nb8XoMNdj;+rIaLYq;AN_AScc2JU{#8a+z=tA~Cd);YL38iUKmRHBxn+kXeIxSnnyQ)K7t7m5|)28uV(Q+Vcn?m@1 zpm0t#9Q1ZG7{>98C0j`o^0WtZ0J|^FfN`PId=n@<&7aM7t^vrhX0Hsj74J6|wbN>- zMg;86;gvlKenu}LbeU@Fh@UdH3BFv9`JrNsCa61kwb(~*RbZ)+(DTTG*fsfMJxvV9 z@xZ61mf;1pkg1Va_MByeX8pc{xX>W7i@!l8sTE=(S}te{6lo`^yu43E7K6d_Ly>qI zR|3aD>V~USvP!=zQ?y8@Bl6TY)Ur7