32 KiB
skland.md — the opt-in Skland account binding
server/skland/ reads a player's Arknights account bindings and associates the roles with their game session.
The title hides the 博士代号 label and nickname until binding, then displays them as plain text without an input.
Logging in fetches the callsign from the default role's nickName; choosing another role updates it immediately.
The primary button shows 绑定账号 until a named role is available, then 开始. Unbinding
or an empty role list clears the callsign and restores 绑定账号. Boot and account-match recovery recheck the current
server binding, so a cached manual callsign/entered flag cannot skip it. This fork requires binding for browser entry;
an operator must enable the module to let players enter. When enabled, the server also requires the authenticated
role for room/game requests. Login persists for 30 days through an HttpOnly JWT cookie, including across server restarts.
Password login uses the official Hypergryph account binding service directly; the existing QR/SMS paths use the
signed Skland API. The historical module name, URL paths and enable switch are retained for compatibility.
It is off by default and stays off unless an operator turns it on
(§2); an unconfigured server keeps no account credentials at all. It uses the public website's account endpoints and
the community-documented Skland endpoints (§10); enabling it and storing credentials is the operator's decision, not a
feature every server gets. The module reaches the network only when the server does; the tests inject a stub fetchImpl.
1. What it is, and what it is not
It is a per-player credential store plus a login client and a small JSON HTTP surface. It does: run the three account login paths (扫码 / 验证码 / 密码), fetch the account's bound roles, refresh an expired credential once, and answer the binding state to the game. It does not solve 人机验证 (§4), spend any credential, or post anything on the player's behalf. The game entry uses a reconnect token for transport and a server-verified JWT for account ownership (§5). The independent diagnostic routes are closed by default; an administrator key enables their caller-supplied ids, excluding the game's reserved pending/account namespaces.
2. Turning it on
| Switch | Effect |
|---|---|
SP_SKLAND=1 |
enables the module (exactly 1; any other value leaves it off) |
SP_SKLAND_DB=<path> |
the SQLite file (default <repo>/data/skland.db) |
SP_AUTH_SECRET=<secret> |
optional HS256 signing secret, at least 32 bytes; otherwise generated once and persisted in the database |
SP_PUBLIC_ORIGIN=https://game.example.com |
optional canonical browser origin, useful behind a reverse proxy |
SP_SKLAND_DIAGNOSTIC_KEY=<secret> |
enables administrator diagnostic routes; callers must supply X-SP-Diagnostic-Key |
startServer({ skland: true }) |
same as SP_SKLAND=1 |
startServer({ skland: {…} }) |
enables it and takes storeFile, fetchImpl (tests), client, scanTimeoutMs, authSecret, publicOrigin, diagnosticKey; authNow injects the test clock |
startServer returns the live service as srv.skland, or null when the module is off; it logs
[skland] account binding on — <file> once. On an unconfigured server no store is created and no credential is read or
written. The off state still owns its paths: routes.js mounts createSklandOffHandler, so every /skland/*
request answers one uniform JSON 404 { ok: false, error } — without it a GET would fall through to the static mount
(a 404) and a POST to the GET/HEAD gate (a 405 HTML page), two shapes for one API.
3. The three login paths
QR/SMS converge on grant → cred (user/oauth2/v2/grant then zonai .../generate_cred_by_code), then
finishLogin persists the row (server/skland/service.js). Password login instead queries the official account
binding service and stores its result without obtaining a Skland credential.
The Skland QR path is retained for both official-server and Bilibili-server bindings; role extraction preserves every Arknights channel returned by Skland. The phone/password form authenticates a Hypergryph account. Bilibili players use Skland QR login and complete any authorization redirect in the provider's interface, as reported by the user; the panel continues polling the same scan session. This integration does not assume that Bilibili offers a phone/password endpoint, or that authorization is its only possible login method.
| Path | The player | The server calls | Comes back |
|---|---|---|---|
| 扫码 | scans the QR (scanUrl, a hypergryph://scan_login?… deep link), confirms on the phone |
gen_scan/login → scan_status (polled) → token_by_scan_code |
token, hgId, deviceToken |
| 验证码 | receives the SMS code (send_phone_code, type 2) and types it |
token_by_phone_code |
token, hgId, deviceToken |
| 密码 | types phone + password | token_by_phone_password → binding-service grant (type 1) → binding_list |
HG token and Arknights bindings; no Skland cred required |
Password lookup follows the official account SDK,
checked on 2026-10-14: Account.API.grantApp('binding') posts { token, appCode: 'be36d44aa36bfb5b', type: 1 }
to https://as.hypergryph.com/user/oauth2/v2/grant, receives a service token (not a Skland grant code), then
GETs https://binding-api-account-prod.hypergryph.com/account/binding/v1/binding_list with that token and
appCode=arknights. A previous local QR/SMS login or stored deviceToken is not a prerequisite.
Only a real upstream status 109 (NEED_DEVICE_VERIFY) becomes { ok: false, error, needDevice: true };
an incorrect password remains its own error. The 密码 endpoint takes phone + password only; passwords are never stored.
The Arknights account page also uses this SDK. Its /user/api/role/login
request consumes an opaque role token issued by /account/binding/v1/u8_token_by_uid; the website does not locally
encrypt the password into that token. Reading the binding list already supplies the requested account information,
so this module does not need that later website-session login request or its analytics fields.
send_phone_code uses 2 (LOGIN_OR_REGISTER) for both the initial send and a captcha retry, following the
official account center login implementation, checked on
2026-10-14. The previous type 24 came from an Endfield-specific example rather than this web login flow.
The endpoint may answer status 102 (发送频率太高) — a readable error, not a captcha.
For general/v1/scan_status, both status 100 (未扫码) and 101 (已扫码待确认) return
state: 'waiting', with awaitingConfirmation: false/true respectively. The title's scan tab automatically fetches
a QR and replaces it every 10 s. The first 101 starts a 60 s refresh window; repeated 101 responses do not extend it.
The manual 重新获取二维码 action stays available. Automatic/manual replacement keeps the existing image visible
until the new QR is encoded, then updates its source without removing the image. The same QR session keeps polling every 2 s, including while the phone confirmation propagates;
only the response carrying data.scanCode advances to the token exchange. Treating 101 as an error stopped the panel
before it could observe the user's confirmation. This mapping follows the official account center
and its login implementation, checked on 2026-10-14.
Other failure statuses (including 102, expired on this endpoint) still surface as errors rather than waiting forever.
QR generation and polling are serial: confirmation wins over a queued refresh, and the latest status is checked
before automatic replacement. Leaving the scan tab or hiding the page pauses the timers; returning resumes the
same QR and checks its status before replacing it. Closing the dialog or binding successfully stops the timers.
Failures remain visible and retry QR generation after 10 s. The independent diagnostic page retains manual QR
generation; its compatible waiting state is unchanged.
4. 人机验证 (geetest)
SMS sending, password authentication and the official account grant can request verification as status: 1 with
data.captcha. The client raises SklandCaptchaError; the service returns { ok: false, needCaptcha: true, captcha },
plus captchaStep: 'password' | 'accountGrant' for password login.
The panel displays a verification button. After the user clicks it, public/js/skland/captcha.js lazily loads the
Geetest provider's widget. The user completes the challenge in that widget; cancellation and load errors leave a
retry button. No phone, password or account token is passed to Geetest by this module. Geetest v4 challenges use
geetestId and riskType; the provider's validation fields plus the original challenge are returned exactly as
the official HG SDK does. Legacy v3 challenges use gt, challenge, new_captcha and return the provider's three
geetest_* fields. The proof is sent in captcha on the original request, never generated by the server.
Password retries include captchaStep. A challenge on accountGrant temporarily retains the authenticated HG token
in server memory, usable for 120 seconds, so its retry uses the same token without repeating the password exchange.
Pending tokens are never sent to the browser or persisted before login completes; unbind discards them.
5. The HTTP surface
Game entry
The title uses /skland/session/<endpoint> with Authorization: Bearer <game reconnect token> and same-origin
cookies on every request, including reads and SMS sending. It establishes the existing hello/welcome transport
without entering the lobby. Invalid or expired transport tokens receive JSON 401. Tokens never appear in URLs,
QR codes or provider requests. Supplied player, UUID, UID or server fields cannot choose another account's row.
Before login, the server uses a reserved @session/<playerId> pending row. After provider confirmation it copies
credentials to a random @account/<subject> row, clears the pending credentials and issues sp_account, a signed
HS256 JWT cookie: HttpOnly, SameSite=Strict, Path=/, Max-Age=2592000; HTTPS uses Secure. JWT contents are signed,
not encrypted, and contain only issuer/audience, random subject/login id, expiry and the selected role key.
Provider tokens, passwords and signing keys never enter it. Verification requires the fixed algorithm, signature,
issuer, audience, time bounds, matching server login record and an active binding.
The following endpoints have the same request/response shapes as the diagnostic table below, with no player needed:
status, roles, scan/start, scan/poll, phone/send, phone/login, password/login, unbind.
The additional POST role/select accepts { roleKey } and selects only an owned, named role with a nonempty UID
and region. status/selection also return loginId, selectedRoleKey and gamePlayerId. Selection is persisted
in the server login record and JWT. On login the default valid role is selected, otherwise the first valid role;
refresh preserves a still-valid selection or chooses a replacement. An empty list cannot start the game.
Changing roles or unbinding while in a room is rejected; leave first. The selected nickname uses the existing
callsign sanitizer, but never determines ownership. Binding itself does not press Start.
shared/accountIdentity.js defines identity as the provider-reported server region (channelName) plus normalized
string UID. Optional gameId and mutable nickname are excluded, so QR and password answers may omit gameId
without changing identity. The game exposes a stable a_ + base64url of the first 16 SHA-256 digest bytes of that tuple
as playerId (24 characters; n:<playerId> fits the existing 32-character field id limit), uses the existing
registry/lobby maps, and permits duplicate nicknames. Different regions with the same UID remain distinct.
Authenticated WebSocket hello ignores client-claimed name/identity and verifies that the supplied reconnect token
belongs to this role; every room/game frame rechecks the live login and selected role. A bare reconnect token cannot
resume an account identity, and a remembered cookie alone establishes only login transport. An anonymous socket can
establish login transport and ping, but cannot issue room/game requests. Explicit entry is described below.
New tabs and restarted servers restore through the persistent cookie/database without another provider login. Unbind revokes the server login, clears its credentials/cookie and invalidates old JWTs, including open sockets; expired cookies require binding again. Login/refresh replies arriving after unbind cannot restore cleared secrets. Deleting the database or rotating the signing secret invalidates remembered logins. Room and match state remain in memory and do not survive a server restart.
Cross-device match recovery
The title's 恢复账号对局 reads server state for the selected role instead of the browser's recent-token ring. A new device signs in independently and selects the same provider region + UID; it need not copy a reconnect token, JWT or localStorage. Restoring the login cookie, opening the title and querying recovery do not take over the seat.
These additional account routes use the same live transport header, JWT, same-origin checks and rate limit:
| Method | Session endpoint | Request | Response |
|---|---|---|---|
| GET | recovery |
none | { ok: true, recovery: null | { id, name, code, mode, difficulty, inMatch, round, connected, spectating } } |
| POST | enter |
{} |
{ ok: true, gamePlayerId, token, name }; starts a game transport or resumes the retained seat |
| POST | resume |
{ id } from the recovery response |
same as enter, but only while the selected role still owns that room/match |
The recovery id is a selector, not a credential. It is unique to the in-memory room and match count; a new room or
later match invalidates it. Supplied player/UID/region/nickname fields never select another role. A missing, foreign
or stale recovery returns JSON 409 with code: 'resume_expired', without creating a game session. A read does not
extend a disconnect deadline and contains no token or private match view. Entry/recovery credentials stay in the
POST response body, never in a URL or persistent browser storage before the server acknowledges them.
After an explicit Start or recovery confirmation, the client stages that token, reconnects with the cookie and waits for welcome to confirm the expected gamePlayerId before entering. The server keeps the same Match/PlayerState and reuses the existing room/state resync. A successfully attached account socket rotates the reconnect token and closes the old socket with 4001; obtaining the token without attaching does not kick the active device. Old tokens cannot silently retake the seat. Automatic boot entry requires the tab's current token to have resumed the selected role; a stale or duplicated tab stays at the title. Its user may still explicitly start/recover with a valid account login. The connection banner's 在此页面继续 uses the same checked account entry, not an obsolete token. Any anonymous welcome clears the entered flag, including a connection reset on an already open page. Ordinary reconnects retain the existing live-tab claim checks. Module-off sessions do not rotate their tokens.
The existing lifetimes still apply: solo matches keep a disconnected seat for 24 hours; co-op matches for 10 minutes; a waiting room also has its 60-second lobby grace. Actual expiration cleans the old seat through lobby.onExpire before the stable role id can be reused. Changing devices does not save the match to SQLite, synchronize local UI preferences/statistics, or make it survive server restart. The UI supplies loading, empty, stale/error and refresh states and warns when continuing may disconnect another device.
Independent diagnostic page
These routes require SP_SKLAND_DIAGNOSTIC_KEY and the matching X-SP-Diagnostic-Key header; otherwise they
return 403. Do not embed this administrator key in browser code. skland.html is a development diagnostic page;
the main title uses the account-scoped endpoints above. Reserved @session/ and @account/ ids are forbidden.
Every answer is JSON, including errors ({ ok: false, error }), so a client is never handed the module-level HTML
error page. Bodies are capped at 64 KiB. The handler is handed requests before the GET/HEAD gate, so it owns POST.
| Method | Path | Request | Response |
|---|---|---|---|
| GET | /skland/status |
?player= |
{ playerId, status, bound, nickname, hgId, roles, error? } |
| GET | /skland/roles |
?player=&refresh=1 |
{ ok, roles } (200 even on failure) |
| POST | /skland/scan/start |
{ player } |
{ ok, scanId, scanUrl, expiresInMs } |
| POST | /skland/scan/poll |
{ player } |
{ ok, state: none | waiting | confirmed | expired | error, scanUrl?, awaitingConfirmation?, status?, nickname?, error? } |
| POST | /skland/phone/send |
{ player?, phone, captcha? } |
{ ok } or { ok: false, needCaptcha, captcha } or { ok: false, error } |
| POST | /skland/phone/login |
{ player, phone, code } |
{ ok, nickname, roles } or { ok: false, error } |
| POST | /skland/password/login |
{ player, phone, password, captcha?, captchaStep? } |
{ ok, nickname, roles }, { ok: false, needCaptcha, captcha, captchaStep } or { ok: false, error, needDevice? } |
| POST | /skland/unbind |
{ player } |
{ ok: true } |
status is unbound | scanning | bound | error; bound is status === 'bound'. A missing player (or phone /
code / password, per endpoint) is a 400, an unknown /skland/* path a 404, a non-POST method on a POST path a 405, and a
malformed body a 400. One 扫码 poll both reports confirmed and completes the login; a poll with no live session is
state: 'none'.
curl -s 'http://127.0.0.1:3000/skland/status?player=p1' -H 'X-SP-Diagnostic-Key: <admin-secret>'
curl -s -X POST http://127.0.0.1:3000/skland/phone/login \
-H 'X-SP-Diagnostic-Key: <admin-secret>' -H 'content-type: application/json' -d '{"player":"p1","phone":"13800000000","code":"123456"}'
6. The database
One row per player in skland_players (node:sqlite, journal_mode = WAL, synchronous = NORMAL), file from
SP_SKLAND_DB / storeFile (default data/skland.db; the parent directory is created). The store is synchronous;
callers never await. The enabled module requires Node with node:sqlite (22.5 or newer); it does not silently
replace persistent authentication with an in-memory fallback.
| Column | Holds |
|---|---|
player_id |
reserved pending/account subject key, or administrator diagnostic id — primary key |
access_token |
the HG login token (refreshes official bindings or rebuilds a Skland cred) |
device_token |
the device token of a 扫码/验证码 login (optional on Skland grants; unused by official account lookup) |
hg_id |
the Hypergryph account id (hgId) |
cred |
the long-term API credential |
cred_token |
the HMAC signing key — never leaves the server |
user_id |
the Skland internal user id (the column exists; no code path writes it) |
d_id |
the device fingerprint the credentials are pinned to (B + 32 hex, per player) |
nickname |
the profile name from the binding answer |
binding |
the raw binding list from the selected provider (cache; parsed back on read) |
binding_source |
skland for QR/SMS, hypergryph for official password lookup; selects the refresh path |
status |
unbound | scanning | bound | error |
error |
the last failure message, if any |
created_at, updated_at |
epoch ms |
update() merges only the named fields; remove() drops the row. 解绑 (unbind) clears every credential field —
including device_token and d_id — and marks the row unbound. The row itself stays. Password login can be
attempted again directly. Existing databases receive the binding_source column automatically, defaulting their
rows to skland so the already working QR/SMS bindings continue to refresh through their original provider.
Successful password lookup clears any previous Skland credentials and stores only the HG account token and bindings.
The same SQLite file holds account_meta (the generated JWT signing secret) and account_logins
(id, subject, role_key, expires, revoked). These tables enforce expiry, selection and logout revocation.
Back up the file consistently with its WAL state and treat backups as credentials too.
7. Credential refresh
Official password bindings refresh by obtaining a new binding-service token from the stored HG account token and
querying the official binding list again. An expired HG token requires login again. QR/SMS getBinding retries
once on the two expiry codes, otherwise the player rebinds:
- 10000 —
cred_tokenexpired →POST zonai /api/v1/auth/refresh(headercred), retry with the newtoken. - 10002 —
credexpired → re-rungetGrantCode(access_token, device_token)thengetCred, retry. Needs a storedaccess_tokenordevice_token; without either it falls through to the error below. - Any other code, or a failed retry: the upstream message is raised; one matching 未登录 reads as
登录已过期,请重新绑定.service.roles(player, true)re-queries only whilestatus === 'bound'and persists the refreshed credentials.
8. Signing
server/skland/crypto.js is pure and fully unit-tested against pinned vectors (test/skland/crypto.test.js) — the
composition is the part a refactor breaks silently. With header_ca = {"platform":"3","timestamp":<ts>,"dId":<dId>, "vName":"1.0.0"} in that key order and ts = floor(now/1000) - 2 (2 s behind):
GET: raw = path + query + ts + compact(header_ca)
POST: raw = path + compact(body ?? {}) + ts + compact(header_ca)
sign = md5( hmacSha256_hex(cred_token, raw) ) // SHA-256 HMAC, lowercase hex, then MD5 of that hex
The headers are cred, dId, platform: 3, timestamp, vName: 1.0.0, sign plus the Skland mobile client
identity (User-Agent: Skland/…, X-Requested-With: com.hypergryph.skland, Accept-Encoding: gzip). Only
getBinding / the exposed signedGet / signedPost are signed; generate_cred_by_code is not. The login endpoints
send deviceHeaders() instead — a X-DeviceId (10 hex chars), X-DeviceModel and X-DeviceType: 2, cached for the
process so the upstream sees one device.
9. 安全模型 (security model)
- Ownership comes from server-verified credentials. Account routes need a live transport token plus the JWT for account operations. UUID/UID/region edits do not confer ownership; role selection is checked against stored provider bindings. WebSocket identity and authorization are server-enforced too. Administrator diagnostic routes are closed by default and cannot access reserved rows. No arbitrary upstream URL/proxy endpoint is exposed.
- Same-origin requests and bounded traffic. Cross-site Origin/Fetch Metadata requests are rejected. Account HTTP requests share a per-address token bucket (3/s, burst 120); request bodies are capped at 64 KiB. Use HTTPS for remote deployments. A fully stolen valid bearer/cookie remains a credential until expiry/revocation; JWT signing prevents modification, not copying. HttpOnly keeps the JWT out of JavaScript storage.
- A
cred(withcred_token) is equivalent to account access. It is a long-term API credential, not a session cookie. Treat it as a secret. - The store keeps credentials in plaintext SQLite. A deployment must treat the database file as a secret: restrict its permissions to the server account, keep it out of backups and repositories, and never serve or expose it over HTTP. The built-in static/media/pack handlers deny the configured DB, WAL and SHM paths, including realpath aliases and Windows filename casing. A reverse proxy or other file server needs its own protection. This is why the module is opt-in.
- A player can 解绑 after leaving their room; that clears the stored credentials. Changing the Hypergryph password invalidates the stored credentials upstream, so the player rebinds.
10. Legal and project rules
The account flow follows the public official SDK; the legacy Skland flow also follows community descriptions.
These website endpoints have no compatibility guarantee and may change without notice. The appCode in crypto.js is a public SDK constant visible in a
decompiled client — it is not an authorisation or a permission. This project is non-commercial
(AGENTS.md forbids ads, payments and other monetisation) and stays that way. Enabling the module, and
the handling of the credentials it stores, is the operator's decision and their responsibility.
11. Tests
Account test files (node --test 'test/skland/*.test.js'), against a stub upstream or widget — no account network.
| File | Pins |
|---|---|
test/skland/crypto.test.js (10) |
the signature composition (GET/POST, with and without query/body), the primitives, stability under a single changed input, the client identity headers, makeDeviceId, the cached deviceHeaders |
test/skland/store.test.js (10) |
one row per player, full credential round-trip, merge-only update, unbind clearing credentials, WAL persistence, parent-dir creation, and migration preserving old QR credentials while persisting the official binding source |
test/skland/client.test.js (21) |
QR/SMS chains, 100→101→confirmed polling, captcha retry, password → official binding-service grant/list without Skland, real device challenge 109, malformed grant/list and upstream errors, signed Skland requests and credential refresh |
test/skland/http.test.js (24) |
server wiring, QR polling with official/Bilibili channel preservation, SMS/captcha, fresh password login without Skland credentials, password/grant captcha continuation on the same token, pending-login invalidation on unbind, actual device challenge and incorrect password, provider-aware role refresh and switching, player isolation and error responses |
test/skland/captcha.test.js (4) |
fake Geetest v3/v4 widgets: exact proof/challenge hand-off, cancellation, missing proof, unsupported challenge and provider load errors; no real challenge or network |
test/skland/docker.test.js (8) |
the one-command packaging: the compose service, the port, SP_SKLAND/SP_SKLAND_DB, the database path inside the mounted named volume (and outside every static mount), the Dockerfile's prepared+owned directory before USER node |
test/skland/panel.test.js (10) |
public/skland.html: every endpoint it calls, the QR encoding scanUrl (not the scanId), the 2 s poll with its cleanup, explicit poll errors, the needCaptcha path and the "not enabled" answer |
test/skland/api.test.js (5) |
the title adapter's token-only authorization header, error mapping, hello reuse and default/selected role choice |
test/skland/scan.test.js (7) |
deterministic 10/60 s refresh boundaries, serial polling, manual refresh, pause/resume, late responses, completion retry and failure recovery |
test/skland/session.test.js (5) |
JWT signature/claims, role ownership, cross-origin rejection, logout/replay, restart/expiry, DB download denial, late provider cancellation, same-name UID/region isolation and authenticated game transport |
test/skland/entry.browser.test.js (1, opt-in) |
the real title/modal with a stub provider: QR, role/channel selection, password CAPTCHA, SMS, hidden/plain-text callsign, empty roles, unbinding, cached-entry checks, reload persistence and short landscape layout; skipped unless SP_E2E=1 |
test/skland/recovery.test.js |
independent logins restoring the same live Match/private state, UID/region and role-selection isolation, read-only lookup, explicit takeover/token rotation, stale selectors, unchanged solo/co-op windows, expired-seat cleanup and account-id compatibility with watch/kick/battle reports |
test/skland/recovery.browser.test.js (1, opt-in) |
two isolated browser contexts with no shared saves: real title/login/recovery, exact state restoration, manual continue, empty/expired recovery, old-device reload and duplicated-tab safety, desktop/landscape-phone layout; SP_E2E=1 |
12. Files
| File | Holds |
|---|---|
server/skland/crypto.js |
the signature, the appCodes, the device id and the login device headers |
server/skland/client.js |
the three login chains, captcha, getGrantCode/getCred, the signed client, getBinding refresh |
server/skland/store.js |
the SQLite credential schema, migrations and merge-only store |
server/skland/service.js |
per-player orchestration, scan sessions, status/roles, finishLogin, unbind |
server/skland/http.js |
administrator diagnostic JSON routes and bounded body reader |
server/skland/auth.js, accountHttp.js |
persistent JWT/account ownership, revocation, stable game identity and scoped HTTP entry/recovery |
server/skland/recovery.js |
read-only selected-role seat metadata and room/match recovery selectors |
shared/accountIdentity.js |
shared UID + server-region key, independent of nickname and optional provider metadata |
public/js/skland/captcha.js |
user-operated Geetest widget, loaded only when verification is needed |
public/js/skland/entry.js, api.js |
title binding button/dialog, token-authenticated client, role selection and QR loading |
public/js/skland/gate.js, resume.js |
checked account entry, safe automatic reconnect and the cross-device recovery picker |
public/js/skland/scan.js |
automatic QR generation, 10/60 s refresh and serial 2 s confirmation polling |
public/css/skland/binding.css |
feature-scoped styles, loaded by the entry without editing global stylesheets |
server/skland/session.js |
reconnect-token resolver and the reserved game-binding namespace |
13. Keeping upstream merges small
Login logic, the callsign display, entry validation and CSS live under the module's own directories. The title
renders these components, owns the dialog-open state and chooses 绑定账号/开始 from the verified nickname. main.js
checks cached entry through restoreBoundEntry. The title mounts the account recovery picker; the obsolete local
shortcut is removed from the lobby (the generic ui/resumeMatch.js helper remains available upstream). The identity
helper stages explicit account tokens without persisting a claim before welcome. The server factory installs an
optional account policy: small hooks in server/net.js cover connection, hello, successful attachment/token rotation
and per-frame authorization; static handlers accept a private-file denylist. With no policy the upstream transport
behaves as before. No server lobby, match, simulation or WebSocket message changes are needed.
Language packs and this reference hold translations/documentation.
Keep future account changes in these module files and preserve the small title/boot/recovery hooks when merging
upstream. This reduces the conflict surface; edits to the same hooks/translations may still need a merge.
The Hypergryph and Bilibili brand images are referenced directly from the
official account page bundle:
hypergryph.d69547.png and
bilibili.11810a.png, checked on 2026-10-14.
Images are decorative beside text labels, so a failed image request leaves the controls usable. No game art is added
to git. The human-operated CAPTCHA temporarily releases the game's modal focus trap for the provider widget.