0.2.3: Clementia S2 releases a dead, hidden, uncarriable or pathless rider at once (slot and weight back before the same tick's boarding checks); same-browser match recovery never takes the seat of a window that is reloading or still starting (a tab keeps its own session token; seats are stamped at the server's welcome, the older welcome wins); golden: diy-135 moves (outcome unchanged)

This commit is contained in:
sganggs committed 2026-10-10 01:58:06 +08:00
1 parent 821ab827bd
commit ba5b998a72
19 files changed
+580 -60

No files matched your search

+5
View File
@@ -174,6 +174,11 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术
A `choice:<effectId>` registry handler overrides the default application (§2.4).
### 1.3 Disconnects, AI takeover
* Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token,
and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a
connected seat when no replacement was allowed or the request has a later stamp than the attached holder
(`SESSION_IN_USE`). Equal stamps still allow normal reload/reconnect. These hints do not authenticate players
and do not change the bearer token or expiration rules below.
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
the deadline (an open 教鞭 choice is picked at random first, §2.5; then the temp pieces due at that prep are sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
(normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays
+3 -1
View File
@@ -201,7 +201,9 @@
单独选择中文、日语或跟随全局语音,只保存在当前浏览器,日语缺失时沿用中文回退。
同一浏览器的对局窗口意外关闭后,可以从标题或大厅的「恢复本机对局」选择保存的席位。仍被其他窗口使用的席位
不会被接管;服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
(包括正在刷新、初始化的窗口)会保留给原窗口;刷新只尝试本窗口原来的身份,不会自动换成另一个近期身份。
恢复在当前页面重新连接,服务器确认后才保存新身份;发生竞争时,较早获得服务器确认的窗口优先。
服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
浏览器提供安装条件时会显示「添加到桌面」。安装后仍需连接游戏服务器,不会把服务器或整套素材离线保存。
不支持页面内安装按钮的浏览器可使用其自己的菜单(是否可用由浏览器决定)。
+5
View File
@@ -535,6 +535,11 @@ flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate
walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`);
`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1).
克莱门莎 S2's kit stores its cabin in `unit.mem.clemntCabin`. Before boarding each tick, it removes passengers
that died, became hidden / uncarryable or cannot path to the next cabin position, removes their carry buff and
returns their current `weight` (including modifiers). The same tick's count and weight limits use that cleaned
list and budget (DESIGN §28.27); this is local to the kit, not a change to general displacement.
**Hit areas (`body.js`, user playtest #5 item 10).** A regular enemy is a point: in a grid range when the tile of its
position (`round(y)`, `round(x)`) is a range tile, in a radius when its position is (DESIGN §3). A huge enemy (巨型单位:
`enemy.hitArea` from data/enemies.json `hitArea` — 假想敌:胄 / 管 / 盐风主教昆图斯 / 阿利斯泰尔 / “萨米的意志”, PRTS
+12 -2
View File
@@ -101,8 +101,18 @@ room loadouts and their exports.
The title and lobby offer explicit recovery of a previously saved local match seat. `sp.matches` stores bounded name /
room-code metadata indexed by token hash; the existing four-entry `sp.tokens` ring remains the authority. Recovery
rechecks BroadcastChannel claims before adopting the selected token, then reloads. It does not identify players by
name or IP or displace a live tab. Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
rechecks BroadcastChannel claims before selecting the token in memory and reconnecting in the same document.
Only the server's `welcome` (`saveToken`) persists that selection and its first-welcome stamp (`sp.tokenWelcome`,
bound to the token hash). Refreshing or reconnecting the same token preserves that stamp. A tab with a session token
initializes with that token or null, never another recent token, and answers ownership queries during initialization.
An earlier welcome takes precedence; equal stamps prefer an already welcomed live document over a copied session,
then tab id breaks ties. A session token without a stamp still outranks a tentative shared-token selection. A pending
recovery yields when the original owner returns; a later welcomed holder also gives up its local claim and pending
reconnect. `hello.noReplace` prevents tentative recovery from evicting a connected session even if its hello is already
in flight; `hello.claimAt` preserves the earlier-welcome priority for established holders' in-flight reconnects. A stale
welcome never reaches the store or flushes queued game actions. Nickname persistence also waits for welcome, so a
refused recovery can return to the previous identity and nickname. It does not identify players by name or IP.
Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
obsolete metadata on the next welcome. A spectator never registers a recoverable player seat.
`manifest.json` enables browser installation; its original fortress favicon is packaged as actual 192/512 px icons in
+3
View File
@@ -172,6 +172,9 @@ Profession traits (default behaviours implemented once in the engine, keyed by `
Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core).
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2).
克莱门莎 S2 removes dead, hidden, self-bound / teleport-immune and unreachable passengers before checking new
boarding in the same tick. Each released passenger returns its current weight to the cabin's remaining budget
and frees its slot (§28.27); neither corpses nor failed carries reserve capacity.
- bonds: 23 (battle + meta), exact per-layer formulas from research 02.
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
远牙's granted `garrison_108_a/b` additionally gains on both directions of `dollSwap`, sharing its deployment
+7
View File
@@ -14,6 +14,13 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
Local recovery arbitration (0.2.3, §28.27): `hello` also accepts optional `noReplace:boolean` and
`claimAt:number` (finite, nonnegative ms). A shared-token candidate sends `noReplace:true`; a tab with a saved
welcome stamp sends `claimAt`. When that session is already connected to a different socket, `noReplace` or a later
stamp than the attached holder causes `SESSION_IN_USE` before the old socket is touched. A same-stamp own reconnect
can still replace its previous socket. Omitted hints retain legacy behavior; they are not authentication. Client
selection/stamp rules live in design/client.md. In-flight protection requires the updated server as well as client.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady {setupRevision?}` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
+40 -2
View File
@@ -284,9 +284,9 @@ installability check runs in Chrome's ordinary temporary profile. Native OS inst
Reproduced with an idle window retaining its own session while a second match window closes. The title/lobby now
lists local saved match seats using bounded metadata beside the existing recent-token ring. Explicit selection asks
live tabs to claim the chosen token again before adopting it and reloading. An active tab is never deliberately
live tabs to claim the chosen token again before adopting it (same-document reconnect since §28.27). An active tab is never deliberately
replaced; names/IP are not authentication. Missing BroadcastChannel refuses recovery. A server-generated replacement
token or leaving clears obsolete metadata. No server API changes; normal automatic startup selection is preserved.
token or leaving clears obsolete metadata. The initial feature changed no server API; §28.27 later adds optional hello guards and keeps startup on the tab's own identity.
Tests: live tab protection, closed-tab recovery, simultaneous claim arbitration, no channel/unknown id, malformed
storage and expired metadata; a real two-window browser restores the identical server playerId and room after closing
@@ -417,3 +417,41 @@ clear time and end reason. All other 284 scenarios, including the five other gol
The focused checks cover all four tier/elite forms, selectability/control/cooldown boundaries, actual casts,
healing chains and enemy splash. Full CI passes: 5879 tests, 5876 passed, 0 failed, 3 skipped; setup, smoke, lint,
imports and typecheck pass. This round changes no production UI and does not rerun the opt-in browser suites.
### 28.27 Cabin capacity and local recovery races — #431
- **Observed:** 克莱门莎 S2 removed the carry buff from dead, hidden, uncarryable or unreachable passengers but
retained them in the passenger list and weight budget. Two weight-3 riders blocked a replacement after one died;
a dead zero-weight rider still occupied one of the 15 slots; a dead weight-4 rider retained four budget points.
- **Now:** after moving the cabin, release invalid passengers and return each one's current weight before either
boarding limit is checked that tick. Remaining passengers keep their order and movement. This corrects bookkeeping
under the existing S2 rules and official blackboard limits (sources in §28.1); no new gameplay assumption is added.
- **Observed (#431):** a recovery attempt with a smaller tab id could claim a token while its owner was refreshing
or still initializing. The original page then fell back to another recent identity, or erased its own token.
- **Now:** startup with a session token chooses only that token or null and answers ownership queries immediately.
The first server welcome for a token stamps its ownership; reload/reconnect preserves that stamp. Earlier welcome
wins a conflict, a live document wins an equal-stamp duplicate, and tab id resolves remaining ties. Explicit recovery
keeps its choice only in memory and reconnects in the same document; only welcome persists it. An original owner
returning cancels the lower-priority choice, including a later welcomed holder's reconnect. Nicknames and IPs remain
irrelevant to identity; a refused recovery retains the previous nickname until its own welcome.
- **In-flight handshake:** a hello can reach the server after another connection's welcome. Optional `hello.noReplace`
makes shared candidates refuse a currently connected seat; `hello.claimAt` lets the server refuse an established
holder with a later welcome stamp. Refusal is `SESSION_IN_USE`, before rebinding or closing the incumbent socket.
Normal same-stamp reconnects and older clients remain accepted. These are local ownership hints, not authentication;
the bearer token remains the authority. A revoked hello's stale welcome is ignored before going online, flushing
queued actions or notifying listeners, and retries use a new socket. Reconnect-window duration is unchanged.
- **Regression coverage:** `op_clemnt.test.js` adds same-tick replacement for death / hidden / self-bound /
teleport immunity / failed path, the 15-slot and weight-4 probes, and a changed current weight. The eight regressions
fail before the fix and pass after it. `client-static.test.js` covers live / reload / mid-init, no fallback identity,
welcome-only persistence, earlier-welcome priority and a welcome arriving during init. The real-browser recovery
test holds a refreshing owner's initialization open while another page clicks recovery and checks both server
identities and live sockets; closed-window recovery also checks that welcome arrives without a page reload.
`client-identity-handshake.test.js` controls delayed hellos / welcomes through the real client and server handlers,
including a later holder's buffered reconnect, same-stamp reconnect, stale-welcome action suppression and validation.
- **Golden:** all 285 scenarios regenerated. Only `diy-135` moves: releasing S2 capacity permits more later carries
(teleport events 572 → 630), changing movement, attack/heal timing and RNG consumption. Clear time 194.33 → 198.03 s;
outcome remains `cleared`, 150/150 kills, zero leaks, one allied death and zero errors. The other 284 scenarios and
the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged.
- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md
(local identity recovery and handshake).
+5 -1
View File
@@ -150,6 +150,7 @@ function onWelcome(msg) {
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
identity.saveName(name);
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
welcomeAt = Date.now();
@@ -214,7 +215,10 @@ function wireNet() {
});
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
net.on('welcome', onWelcome);
net.on('helloError', (err) => toastError(err));
net.on('helloError', (err) => {
if (err.code === 'SESSION_IN_USE') identity.rejectToken();
toastError(err);
});
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
net.on('unhandledError', (err) => toastError(err));
net.on('room.state', onRoomState);
+101 -27
View File
@@ -114,6 +114,7 @@ export class Net {
* @param {string} [opts.url] socket URL (default: derived from location at connect time)
* @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket)
* @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello`
* @param {() => ({ noReplace: boolean, claimAt?: number })} [opts.getTokenClaim] local ownership for this hello
* @param {() => number} [opts.now]
* @param {() => number} [opts.random]
* @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers]
@@ -122,6 +123,7 @@ export class Net {
this.url = opts.url || null;
this.WS = opts.WebSocket || null;
this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null;
this.getTokenClaim = typeof opts.getTokenClaim === 'function' ? opts.getTokenClaim : () => null;
this.now = opts.now || (() => Date.now());
this.random = opts.random || Math.random;
this.timers = opts.timers || {
@@ -157,6 +159,7 @@ export class Net {
this._helloTimer = null;
this._helloRid = null;
this._helloSentName = null;
this._helloToken = null;
this._lastRx = 0;
this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame
this._clockSamples = []; // [{ offset, rtt }]
@@ -254,8 +257,9 @@ export class Net {
}
/** Drop the current socket and reconnect immediately (fresh hello). */
reconnectNow() {
reconnectNow(name = this.name) {
if (this._manualClose) return;
this.name = name;
const ws = this.ws;
this._teardownSocket();
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
@@ -365,7 +369,13 @@ export class Net {
const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION };
let token = null;
try { token = this.getToken(); } catch { token = null; }
if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token;
if (typeof token === 'string' && token.length > 0 && token.length <= 64) {
msg.token = token;
const claim = this.getTokenClaim();
if (claim?.noReplace) msg.noReplace = true;
if (Number.isFinite(claim?.claimAt)) msg.claimAt = claim.claimAt;
}
this._helloToken = token;
this._helloRid = rid;
this._helloSentName = this.name;
if (this.status !== 'handshaking') this._setStatus('handshaking');
@@ -533,6 +543,9 @@ export class Net {
const isHelloError = t === 'error' && rid != null && rid === this._helloRid;
if (t === 'welcome') {
// A local ownership election can revoke a token after its hello went onto the wire.
// Do not publish or persist that stale welcome, even if the server had already accepted it.
if (this._helloToken !== this.getToken()) { this.reconnectNow(); return; }
this._onWelcome(msg);
} else if (t === 'pong') {
this._onPong(msg);
@@ -654,11 +667,15 @@ export class Net {
// browsers throttle background-tab timers to ~1/min, which would make a busy background tab look
// dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a
// new player instead of kicking the original tab off its session (server close 4001). Two tabs
// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses
// resolving shared candidates at the same moment tie-break on tab id. An existing session token
// never falls back to another recent identity. Its welcome stamp survives reload; an earlier
// welcome outranks a later claimant, then a live holder wins over its duplicate, then tab id.
// Without BroadcastChannel a tab only ever uses
// its own token (never another tab's), so it can't steal a live session either.
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_WELCOME = 'sp.tokenWelcome'; // sessionStorage: { hash, at } from the first welcome for this token
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
const K_MATCHES = 'sp.matches'; // local metadata for explicit recovery of another closed tab's seat
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
@@ -714,13 +731,15 @@ function defaultChannel() {
* Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above).
* @param {{ local?: Storage|null, session?: Storage|null, tabId?: string,
* channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null,
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps]
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number, now?: () => number,
* onYield?: () => void }} [deps]
*/
export function createIdentity(deps = {}) {
const local = deps.local !== undefined ? deps.local : safeStorage('localStorage');
const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage');
const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36);
const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
const now = deps.now || (() => Date.now());
const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS;
let channel = null;
@@ -729,6 +748,14 @@ export function createIdentity(deps = {}) {
let resumeBusy = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
let owned = sget(session, K_TOKEN);
if (!isToken(owned)) owned = null;
let welcomeAt = null;
try {
const saved = JSON.parse(sget(session, K_WELCOME) || 'null');
if (owned && saved?.hash === tokenHash(owned) && Number.isFinite(saved.at) && saved.at >= 0) welcomeAt = saved.at;
} catch { /* old / damaged storage: the session token still claims its seat */ }
let welcomed = false; // this document has received welcome (a copied stamp alone is not a live holder)
const readRecent = () => {
try {
@@ -763,23 +790,52 @@ export function createIdentity(deps = {}) {
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
};
// Session ownership is already valid while init is waiting. Shared candidates, including an
// explicit resume, remain tentative until saveToken receives the server's welcome.
const claimFor = (hash) => {
if (owned && tokenHash(owned) === hash) return { owned: true, at: welcomeAt, live: welcomed };
if ((current && tokenHash(current) === hash) || resolving?.hashes.has(hash)) return { owned: false, at: null, live: false };
return null;
};
const peerWins = (peer, from, ours) => {
if (!peer || !ours) return true; // an older client answering "mine" is conservatively treated as live
const a = Number.isFinite(peer.at) && peer.at >= 0 ? peer.at : Infinity;
const b = Number.isFinite(ours.at) && ours.at >= 0 ? ours.at : Infinity;
if (a !== b) return a < b;
if (!!peer.owned !== !!ours.owned) return !!peer.owned;
if (!!peer.live !== !!ours.live) return !!peer.live;
return from < tabId;
};
const yieldCandidate = (hash) => {
resolving?.taken.add(hash);
// The original page can reappear after resume's query but before its hello. Keep our old
// session in storage and give back the tentative selection immediately in that interval.
if (current && tokenHash(current) === hash) {
if (current === owned) {
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_WELCOME);
}
current = owned;
if (owned) sset(session, K_TOKEN, owned);
else sdel(session, K_TOKEN);
if (initialized) deps.onYield?.();
}
};
function onChannelMessage(ev) {
const m = ev && ev.data;
if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return;
if (m.type === 'who' && Array.isArray(m.hashes)) {
if (current) {
const h = tokenHash(current);
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
}
// Another tab is choosing at the same time: the smaller tab id has precedence.
// Answer for pending candidates too: a later channel may have missed our initial query.
if (resolving) for (const h of m.hashes) {
for (const h of m.hashes) {
if (typeof h !== 'string') continue;
if (m.from < tabId) resolving.taken.add(h);
else if (resolving.hashes.has(h)) post({ type: 'mine', hash: h, to: m.from });
const ours = claimFor(h);
if (!ours) continue;
const peer = m.claims?.[h] || { owned: false, at: null, live: false };
if (peerWins(peer, m.from, ours)) yieldCandidate(h);
else post({ type: 'mine', hash: h, claim: ours, to: m.from });
}
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
resolving.taken.add(m.hash);
} else if (m.type === 'mine' && m.to === tabId && typeof m.hash === 'string') {
if (peerWins(m.claim, m.from, claimFor(m.hash))) yieldCandidate(m.hash);
}
}
@@ -789,17 +845,16 @@ export function createIdentity(deps = {}) {
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
else channel.onmessage = onChannelMessage;
}
const own = sget(session, K_TOKEN);
const ownOk = isToken(own) ? own : null;
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
const candidates = owned ? [owned] : channel ? [...new Set(readRecent())] : [];
if (!channel || candidates.length === 0) return owned;
return queryCandidates(candidates);
}
async function queryCandidates(candidates) {
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
post({ type: 'who', hashes: [...resolving.hashes] });
post({ type: 'who', hashes: [...resolving.hashes],
claims: Object.fromEntries([...resolving.hashes].map((h) => [h, claimFor(h)])) });
await new Promise((r) => setTimer(r, queryMs));
const { taken } = resolving;
resolving = null;
@@ -816,11 +871,15 @@ export function createIdentity(deps = {}) {
init() {
if (!initPromise) {
initPromise = resolveToken().catch(() => null).then((pick) => {
const own = sget(session, K_TOKEN);
if (pick) sset(session, K_TOKEN, pick);
else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab
// A welcome may already have set a token while we were waiting: that one wins.
if (!current) current = pick;
if (!welcomed) {
current = pick;
if (pick) sset(session, K_TOKEN, pick);
else {
owned = null; welcomeAt = null;
sdel(session, K_TOKEN); sdel(session, K_WELCOME);
}
}
initialized = true;
return current;
});
@@ -848,7 +907,6 @@ export function createIdentity(deps = {}) {
const pick = await queryCandidates([token]);
if (!pick) return false;
current = pick;
sset(session, K_TOKEN, pick);
return true;
} catch { return false; }
finally { resumeBusy = false; }
@@ -864,12 +922,26 @@ export function createIdentity(deps = {}) {
const own = sget(session, K_TOKEN);
return isToken(own) ? own : null;
},
/** A shared candidate must never replace a connected session, including a hello already in flight. */
getTokenClaim() {
return current === owned && owned
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
: { noReplace: true };
},
/** Only this page gives up the rejected claim; the shared recovery list belongs to all tabs. */
rejectToken() {
if (current) yieldCandidate(tokenHash(current));
},
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
if (current !== token) rememberMatch(null);
if (owned !== token || welcomeAt == null) welcomeAt = now();
owned = token;
welcomed = true;
current = token;
sset(session, K_TOKEN, token);
sset(session, K_WELCOME, JSON.stringify({ hash: tokenHash(token), at: welcomeAt }));
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
@@ -877,7 +949,9 @@ export function createIdentity(deps = {}) {
rememberMatch(null);
const t = current || sget(session, K_TOKEN);
current = null;
owned = null; welcomeAt = null; welcomed = false;
sdel(session, K_TOKEN);
sdel(session, K_WELCOME);
if (t) writeRecent(readRecent().filter((x) => x !== t));
},
/** Whether this tab already passed the title screen (survives reloads, not new tabs). */
@@ -888,7 +962,7 @@ export function createIdentity(deps = {}) {
}
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
export const identity = createIdentity();
export const identity = createIdentity({ onYield: () => net.reconnectNow(net.helloName || net.name) });
/** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */
export const net = new Net({ getToken: () => identity.getToken() });
export const net = new Net({ getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
+10 -3
View File
@@ -1,7 +1,8 @@
// Recover an explicitly selected local seat; nickname and IP never identify a player.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal } from './components.js';
import { identity } from '../net.js';
import { identity, net } from '../net.js';
import { store } from '../store.js';
import { t } from '../../../shared/i18n.js';
import { toast } from './toasts.js';
@@ -21,9 +22,15 @@ export function ResumeMatchButton() {
if (busy) return;
setBusy(true);
if (await identity.resume(item.id)) {
identity.saveName(item.name);
identity.setEntered(true);
location.reload();
store.patch('session', { entered: true });
// Keep the tentative token in this document until welcome persists it. Reloading here
// would either lose the selection or falsely turn it into an established session claim.
net.close();
net.setName(item.name);
net.connect();
setOpen(false);
setBusy(false);
} else {
setBusy(false);
toast(t('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), 'warn');
+8
View File
@@ -96,6 +96,8 @@ export class Session {
this.ws = null;
/** @type {boolean} */
this.connected = false;
/** @type {number | null} local first-welcome stamp of the attached client (optional, not authentication) */
this.claimAt = null;
/** @type {number} ms epoch of the last inbound frame / pong */
this.lastSeen = now;
/** @type {number | null} ms epoch when the socket was lost (null while connected) */
@@ -647,6 +649,11 @@ export class Network {
if (!session) {
session = msg.token ? this.registry.byToken(msg.token) : null;
if (session) {
if (session.connected && session.ws && session.ws !== conn.ws
&& (msg.noReplace || (Number.isFinite(msg.claimAt) && session.claimAt != null && msg.claimAt > session.claimAt))) {
this.reply(conn, errorMsg(ERR.SESSION_IN_USE, rid));
return;
}
resumed = true;
if (session.ws && session.ws !== conn.ws) this.detachReplaced(session.ws);
} else {
@@ -656,6 +663,7 @@ export class Network {
conn.session = session;
session.ws = conn.ws;
session.connected = true;
session.claimAt = Number.isFinite(msg.claimAt) ? msg.claimAt : null;
session.disconnectedAt = null;
}
session.name = name;
+11 -3
View File
@@ -79,6 +79,17 @@ export default {
if (!c.stopped) {
c.moved = Math.min(c.distance, c.moved + SPEED * dt);
c.x = c.ox + c.dc * c.moved; c.y = c.oy + c.dr * c.moved;
// Release before either boarding limit is checked: invalid riders occupy neither a slot
// nor weight. Return their current weight, including modifiers applied during the ride.
for (let i = c.passengers.length - 1; i >= 0; i--) {
const e = c.passengers[i], [er, ec] = tile(e);
if (!e.alive || e.hidden || cannotCarry(e)
|| !battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) {
battle.removeBuff(e, CARRY + unit.id);
c.passengers.splice(i, 1);
c.mass += e.weight;
}
}
const cap = num(b2['attack@max_passenger_cnt'], 15);
for (const e of battle.enemies) {
if (c.passengers.length >= cap) break;
@@ -93,10 +104,7 @@ export default {
battle.addBuff(e, { key: CARRY + unit.id, flags: { noMove: true, disarm: true, unblockable: true }, source: unit, tags: ['skill'] });
}
for (const e of c.passengers) {
if (!e.alive || e.hidden || cannotCarry(e)) { battle.removeBuff(e, CARRY + unit.id); continue; }
if (!e.findBuff(CARRY + unit.id)) continue;
const [er, ec] = tile(e);
if (!battle.grid.findPath(er, ec, Math.round(c.y), Math.round(c.x))) { battle.removeBuff(e, CARRY + unit.id); continue; }
const fromX = e.x, fromY = e.y;
e.x = c.x; e.y = c.y;
if (e.route) e.route.pts = null;
+2
View File
@@ -127,6 +127,7 @@ export const ANIM = Object.freeze({ IDLE: 0, MOVE: 1, ATTACK: 2, SKILL: 3, DIE:
export const ERR = Object.freeze({
BAD_MSG: 'BAD_MSG', // malformed / unknown message
SESSION_IN_USE: 'SESSION_IN_USE', // a local recovery candidate cannot replace the connected holder
RATE: 'RATE', // rate limited
NOT_IN_ROOM: 'NOT_IN_ROOM',
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
@@ -151,6 +152,7 @@ export const ERR = Object.freeze({
});
export const ERR_TEXT = {
SESSION_IN_USE: N_('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'),
BAD_MSG: N_('无效的请求'), RATE: N_('操作过于频繁'), NOT_IN_ROOM: N_('你不在房间中'), ROOM_NOT_FOUND: N_('未找到该同盟密钥对应的房间'),
ROOM_FULL: N_('房间已满'), ROOM_STARTED: N_('模拟已开始'), NOT_HOST: N_('只有房主可以操作'), NOT_READY: N_('仍有玩家未就绪'),
WRONG_PHASE: N_('当前阶段无法进行该操作'), NO_FUNDS: N_('资金不足'), HAND_FULL: N_('整备区已满'), BOARD_FULL: N_('已达到部署上限'),
+2 -1
View File
@@ -378,7 +378,8 @@ const target = (v) => {
/** @type {Record<string, Record<string, (v:any)=>boolean> & { $optional?: string[] }>} */
export const C2S = {
// session & lobby
hello: { name: (v) => isStr(v, NAME_MAX_LEN) && v.trim().length > 0, token: (v) => v == null || isStr(v, 64), version: (v) => v == null || isInt(v, 0, 1e6), $optional: ['token', 'version'] },
hello: { name: (v) => isStr(v, NAME_MAX_LEN) && v.trim().length > 0, token: (v) => v == null || isStr(v, 64), version: (v) => v == null || isInt(v, 0, 1e6),
noReplace: isBool, claimAt: (v) => isNum(v, 0, Number.MAX_SAFE_INTEGER), $optional: ['token', 'version', 'noReplace', 'claimAt'] },
ping: { c: (v) => typeof v === 'number' && Number.isFinite(v) },
'room.create': { mode: (v) => v === 'solo' || v === 'coop', difficulty: (v) => DIFFICULTIES.includes(v) },
'room.join': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) },
+147
View File
@@ -0,0 +1,147 @@
// Control delivery across two connections while using the real client and server hello handlers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createIdentity, Net } from '../public/js/net.js';
import { Network, SessionRegistry } from '../server/net.js';
import { validateC2S } from '../shared/protocol.js';
function storage() {
const m = new Map();
return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) };
}
function hub() {
const members = new Set();
return { create() {
const channel = {
listeners: [],
postMessage(data) {
for (const peer of members) if (peer !== channel) queueMicrotask(() => {
if (members.has(peer)) for (const fn of peer.listeners) fn({ data: structuredClone(data) });
});
},
addEventListener(type, fn) { if (type === 'message') this.listeners.push(fn); },
close() { members.delete(channel); },
};
members.add(channel); return channel;
} };
}
async function harness(t) {
const local = storage(), ownerSession = storage(), guestSession = storage(), channels = hub();
const registry = new SessionRegistry(), server = new Network({ registry, handler: { onMessage() {} } });
const ownerSeat = registry.create('Owner'), guestSeat = registry.create('Guest'), clients = [];
t.after(() => { for (const net of clients) net.close(); clearInterval(server.heartbeatTimer); clearInterval(server.sweepTimer); });
const ownerChannel = channels.create();
const owner = createIdentity({ local, session: ownerSession, channel: ownerChannel, tabId: 'z-owner', queryMs: 1, now: () => 100 });
await owner.init(); owner.saveToken(ownerSeat.token); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
const guest = createIdentity({ local, session: guestSession, channel: channels.create(), tabId: 'a-guest', queryMs: 1, now: () => 200 });
await guest.init(); guest.saveToken(guestSeat.token);
const client = (identity) => {
const sockets = [], welcomes = [], frames = [];
class Socket {
constructor() { this.readyState = 0; this.sent = []; sockets.push(this); }
send(raw) { this.sent.push(JSON.parse(raw)); }
// Client close need not reach the server before an already sent hello on this connection.
close(code) { this.readyState = 3; this.onclose?.({ code }); }
open() { this.readyState = 1; this.onopen?.(); }
}
const net = new Net({ WebSocket: Socket, getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
clients.push(net);
net.on('welcome', (m) => { welcomes.push(m); identity.saveToken(m.token); });
net.setName('Owner');
const ws = sockets[0]; ws.open();
const conn = { session: null, ip: '?', key: null, ws: {
readyState: 1, bufferedAmount: 0,
send(raw) { frames.push(JSON.parse(raw)); ws.onmessage?.({ data: raw }); },
close(code) { this.readyState = 3; ws.close(code); },
} };
return { net, ws, conn, welcomes, frames, sockets,
hello: () => ws.sent.find((m) => m.t === 'hello'),
deliver() {
const msg = ws.sent.find((m) => m.t === 'hello');
assert.equal(validateC2S(msg), null);
server.onHelloMsg(conn, msg, Date.now());
},
};
};
return { local, ownerSession, guestSession, channels, ownerSeat, guestSeat, ownerChannel, owner, guest, client,
async reload() {
const id = createIdentity({ local, session: ownerSession, channel: channels.create(), tabId: 'zz-reloaded', queryMs: 1, now: () => 300 });
assert.equal(await id.init(), ownerSeat.token); return id;
},
};
}
test('a recovery hello already in flight cannot displace the original window after its reload welcome', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const pending = h.client(h.guest);
assert.equal(pending.hello().noReplace, true);
const owner = h.client(await h.reload()); owner.deliver();
assert.equal(h.guest.getToken(), h.guestSeat.token);
pending.deliver();
assert.equal(pending.frames.at(-1).code, 'SESSION_IN_USE');
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
assert.equal(h.ownerSeat.ws, owner.conn.ws);
assert.equal(pending.welcomes.length, 0);
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
});
test('a later welcomed holder also yields; its buffered reconnect cannot override the earlier welcome', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const recovered = h.client(h.guest); recovered.deliver();
assert.equal(recovered.net.status, 'online');
const delayedReconnect = h.client(h.guest);
assert.equal(delayedReconnect.hello().claimAt, 200);
assert.equal(delayedReconnect.hello().noReplace, undefined);
const owner = h.client(await h.reload()); owner.deliver();
assert.equal(h.guest.getToken(), null);
assert.equal(h.guestSession.getItem('sp.token'), null);
delayedReconnect.deliver();
assert.equal(delayedReconnect.frames.at(-1).code, 'SESSION_IN_USE');
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
assert.equal(h.ownerSeat.ws, owner.conn.ws);
assert.equal(h.ownerSeat.claimAt, 100);
});
test('shared recovery can bind an offline seat and a same-stamp reconnect can replace its own socket', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const first = h.client(h.guest); first.deliver();
assert.equal(first.welcomes[0].playerId, h.ownerSeat.playerId);
const reconnect = h.client(h.guest); reconnect.deliver();
assert.equal(reconnect.welcomes[0].playerId, h.ownerSeat.playerId);
const again = h.client(h.guest); again.deliver();
assert.equal(again.net.status, 'online');
assert.equal(again.welcomes[0].playerId, h.ownerSeat.playerId);
assert.equal(h.ownerSeat.claimAt, 200);
});
test('a stale welcome is discarded before online status, queued actions or welcome listeners', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const pending = h.client(h.guest), published = [], statuses = [];
pending.net.on('*', (m) => published.push(m));
pending.net.on('status', (s) => statuses.push(s.status));
const action = pending.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
const rejected = assert.rejects(action, (e) => e.code === 'CLOSED');
h.guest.rejectToken();
pending.deliver();
assert.equal(pending.welcomes.length, 0);
assert.equal(published.length, 0);
assert.ok(!statuses.includes('online'));
assert.ok(!pending.ws.sent.some((m) => m.t === 'room.create'));
assert.equal(pending.sockets.length, 2, 'retry uses a new connection, never a repeat hello on the old session');
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
pending.net.close(); await rejected;
});
test('hello ownership fields reject malformed inputs but remain optional', () => {
const hello = { t: 'hello', name: 'Owner', token: 'seat' };
for (const fields of [{}, { noReplace: true }, { noReplace: false, claimAt: 100 }]) assert.equal(validateC2S({ ...hello, ...fields }), null);
for (const fields of [{ noReplace: 'yes' }, { claimAt: -1 }, { claimAt: Infinity }, { claimAt: '100' }]) assert.equal(typeof validateC2S({ ...hello, ...fields }), 'string');
});
+77
View File
@@ -1310,6 +1310,83 @@ describe('multi-device & browser compatibility (static)', () => {
});
describe('explicit local match recovery (#431)', () => {
async function recoveryPair() {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ownerSession = memStorage(), guestSession = memStorage();
const ownerChannel = hub.create();
const owner = createIdentity({ local, session: ownerSession, tabId: 'z-owner', channel: ownerChannel, queryMs: 5, now: () => 100 });
const guest = createIdentity({ local, session: guestSession, tabId: 'a-guest', channel: hub.create(), queryMs: 5, now: () => 200 });
await owner.init(); owner.saveToken('seat-owner'); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
await guest.init(); guest.saveToken('seat-guest');
return { createIdentity, local, hub, ownerSession, guestSession, ownerChannel, owner, guest, id: guest.recoverable()[0].id };
}
test('live: a smaller tab id cannot reclaim an active seat', async () => {
const { guest, owner, id } = await recoveryPair();
assert.equal(await guest.resume(id), false);
assert.equal(guest.getToken(), 'seat-guest');
assert.equal(owner.getToken(), 'seat-owner');
});
test('reload: the original session wins over a recovery attempt during navigation', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true, 'old document is gone');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5 });
assert.equal(await reloaded.init(), 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest', 'unconfirmed recovery yields before hello');
});
test('midInit: the session token answers claims before init finishes', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
let finish;
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = reloaded.init();
assert.equal(await p.guest.resume(p.id), false);
finish();
assert.equal(await pending, 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest');
});
test('an occupied session token never falls back to a different recent identity', async () => {
const p = await recoveryPair();
p.local.setItem('sp.tokens', JSON.stringify(['seat-free', 'seat-owner']));
const duplicate = p.createIdentity({ local: p.local, session: p.ownerSession.clone(), tabId: '0-copy', channel: p.hub.create(), queryMs: 5 });
assert.equal(await duplicate.init(), null);
assert.equal(duplicate.getToken(), null);
});
test('resume persists neither its token nor a welcome stamp until the server welcomes it', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
const stamp = p.guestSession.getItem('sp.tokenWelcome');
assert.equal(await p.guest.resume(p.id), true);
assert.equal(p.guestSession.getItem('sp.token'), 'seat-guest');
assert.equal(p.guestSession.getItem('sp.tokenWelcome'), stamp);
p.guest.saveToken('seat-owner');
assert.equal(p.guestSession.getItem('sp.token'), 'seat-owner');
assert.ok(p.guestSession.getItem('sp.tokenWelcome'));
});
test('an earlier welcome stamp survives reload and beats a later live welcome', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true);
p.guest.saveToken('seat-owner'); // recovery's welcome is later than the original owner's
const originalStamp = p.ownerSession.getItem('sp.tokenWelcome');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5, now: () => 300 });
assert.equal(await reloaded.init(), 'seat-owner');
reloaded.saveToken('seat-owner');
assert.equal(p.ownerSession.getItem('sp.tokenWelcome'), originalStamp, 'reconnect does not reset seniority');
});
test('a welcome received during init cannot be overwritten or erased by its pending result', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), session = memStorage(), hub = channelHub();
session.setItem('sp.token', 'old');
let finish;
const id = createIdentity({ local, session, channel: hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = id.init();
id.saveToken('new'); finish();
assert.equal(await pending, 'new');
assert.equal(session.getItem('sp.token'), 'new');
});
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
+63 -1
View File
@@ -9,7 +9,7 @@ const C = 'char_4231_clemnt';
const data = Object.fromEntries(['chess', 'backups'].map((f) => [f, JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url)))]));
const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max(1, Math.abs(b)), `${label}: ${a} != ${b}`);
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), heavy: enemy('heavy', { mass: 9 }),
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3 } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs,
@@ -104,6 +104,68 @@ test('风暴潮 caps passengers at fifteen and cleans them on early skill end',
assert.equal(u.mem.clemntCabin, null); done(h);
});
for (const reason of ['death', 'hidden', 'self-bound', 'teleport-immune', 'no-path']) {
test(`风暴潮 releases ${reason} passengers before admitting a replacement in the same tick`, () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
const survivor = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin, carry = `clemnt:carry:${u.id}`;
assert.deepEqual(c.passengers.map((e) => e.id), [victim.id, survivor.id]);
assert.equal(c.mass, 2);
if (reason === 'death') h.b.dealDamage(u, victim, { amount: 1e12, type: 'true' });
if (reason === 'hidden') victim.hidden = true;
if (reason === 'self-bound') h.b.addBuff(victim, { key: 'test:bound', flags: { selfBound: true } });
if (reason === 'teleport-immune') victim.def = { ...victim.def, immune: new Set(['teleport']) };
if (reason === 'no-path') {
victim.x = 7; victim.y = 12;
for (const [r, col] of [[11, 6], [11, 7], [11, 8], [12, 6], [12, 8]]) h.b.grid.setObstacle(r, col, true);
assert.equal(h.b.grid.findPath(12, 7, Math.round(c.y), Math.round(c.x)), null);
}
const replacement = h.spawn('light', { pos: [c.y, c.x] });
h.step();
assert.deepEqual(c.passengers.map((e) => e.id), [survivor.id, replacement.id]);
assert.equal(c.mass, 2);
assert.ok(!victim.findBuff(carry));
assert.ok(replacement.findBuff(carry));
approx(replacement.x, c.x); approx(survivor.x, c.x);
h.step(); assert.equal(c.mass, 2, 'released weight is returned only once');
done(h);
});
}
for (const [kind, count] of [['zero', 15], ['mid', 1]]) {
test(`风暴潮 frees the ${kind === 'zero' ? 'fifteenth slot' : 'four-weight budget'} after a passenger dies`, () => {
const { h, u } = field({ skill: 1 });
const riders = Array.from({ length: count }, () => h.spawn(kind, { pos: [10, 4] }));
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.passengers.length, count);
h.b.dealDamage(u, riders[0], { amount: 1e12, type: 'true' });
const replacement = h.spawn(kind, { pos: [c.y, c.x] });
h.step();
assert.equal(c.passengers.length, count);
assert.ok(!c.passengers.includes(riders[0]));
assert.ok(c.passengers.includes(replacement));
assert.equal(c.mass, kind === 'zero' ? 8 : 4);
done(h);
});
}
test('风暴潮 returns a released passenger’s current weight after a weight modifier', () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.mass, 5);
h.b.addBuff(victim, { key: 'test:lighter', mods: { massFlat: -1 }, flags: { selfBound: true } });
assert.equal(victim.weight, 2);
h.step();
assert.equal(c.passengers.length, 0);
assert.equal(c.mass, 7, 'return current weight 2, not boarding weight 3');
done(h);
});
test('风暴潮 stops before a blocked tile; vortex keeps its continuous stop point, hits air and applies normal erosion resistance', () => {
const { h, u } = field({ skill: 1 }); h.b.grid.setObstacle(10, 5, true);
const fly = h.spawn('fly', { pos: [10, 5.55] });
+17 -17
View File
@@ -5850,38 +5850,38 @@
"round": 13,
"seed": 3994643046,
"waveId": "act1autochess_h06",
"time": 194.33,
"ticks": 5830,
"time": 198.03,
"ticks": 5941,
"reason": "cleared",
"killed": 150,
"total": 150,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1629,
"rngDraws": 1626,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":55519,"deaths":1,"layerGains":{}}},
"players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":56685,"deaths":1,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,1,6436,260,0,9,2],
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,28,337078,4202,0,103,5],
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,41,324348,22963,0,192,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17661,2190,0,32,8],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,56,614536,3652,0,191,6],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1571.84,1,145,24,361361,9590,0,109,2],
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,29,321186,4202,0,103,5],
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,42,323043,24114,0,196,2],
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17637,2190,0,32,8],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,54,636268,3391,0,191,6],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1833.1,1,145,24,356851,9329,0,109,2],
["p1",7,"op","chess_char_6_diy1_b",0,"none",1818.3,1,8,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_diy2_b",1,"none",1818.3,1,12,0,0,0,0,0,0],
["p1",9,"op","chess_char_6_diy1_b",2,"none",1818.3,1,14,0,0,0,0,0,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,23798,64,16],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,21767,55,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16570,9955,33,0],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,24672,66,16],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,22077,56,2],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16846,9935,34,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1249_lysdb_2":[60,60,0,22108,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37149,727834]},
"hooks": [1,166,5830,1043,1043,2055,2562,461,151,151,43,39,24,10080,0,66,150,0,1,151,0,46,0],
"events": {"atk":1542,"deploy":13,"die":151,"dmg":2562,"engage":6,"fx":796,"heal":191,"skill":82,"spawn":165,"status":95},
"fx": {"anchor":11,"blast":72,"burst":46,"lock":24,"shieldBreak":60,"teleport":572,"tide":11},
"snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 6b457668 276d43d1 2bfa5ab2 cc45bcda 05a317f3"
"enemies": {"enemy_1249_lysdb_2":[60,60,0,22385,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37777,727834]},
"hooks": [1,166,5941,1054,1054,2053,2565,473,151,151,43,39,24,10083,0,64,150,0,1,151,0,47,0],
"events": {"atk":1558,"deploy":13,"die":151,"dmg":2565,"engage":6,"fx":855,"heal":194,"skill":82,"spawn":165,"status":97},
"fx": {"anchor":11,"blast":72,"burst":47,"lock":24,"shieldBreak":60,"teleport":630,"tide":11},
"snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 216b4871 ad7483be 0e1ae0c5 b71814eb 25d90322"
},
"diy-136": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_a=clemnt/S1 6_diy2_a=clemnt/S2 6_diy1_a=clemnt/S3 6_diy2_b=clemnt/S1 6_diy1_b=clemnt/S2 6_diy2_b=clemnt/S3 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
+62 -2
View File
@@ -14,9 +14,10 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => { await browser?.close(); await server?.close(); });
async function pageIn(ctx, phone = false) {
async function pageIn(ctx, phone = false, random = null) {
const page = await ctx.newPage();
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
if (random != null) await page.evaluateOnNewDocument((value) => { Math.random = () => value; }, random);
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
@@ -40,10 +41,69 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
await b.close();
await a.click('[data-testid="resume-local-match"]');
await a.waitForSelector('.modal .btn--block');
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
await a.evaluate(() => { globalThis.recoveryDocument = true; });
await a.click('.modal .btn--block');
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
assert.deepEqual(after, before);
assert.equal(await a.evaluate(() => globalThis.recoveryDocument), true, 'welcome arrives in the same document');
} finally { await ctx.close(); }
});
test('a refreshing match window keeps its seat while its init is waiting and another window clicks recovery', async () => {
const ctx = await browser.createBrowserContext();
try {
const guest = await pageIn(ctx, false, 0.01), owner = await pageIn(ctx, false, 0.99);
const before = await owner.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await net.request('room.start', {});
return { id: store.get().me.playerId, code: store.get().room.code, token: sessionStorage.getItem('sp.token') };
});
const guestId = await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId);
await owner.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await guest.waitForSelector('[data-testid="resume-local-match"]');
await owner.evaluateOnNewDocument(() => {
// Hold precisely the timeout scheduled immediately after the real identity query.
// Messages still use Chrome's BroadcastChannel; only init's completion is controlled.
const Channel = globalThis.BroadcastChannel, timer = globalThis.setTimeout;
let hold = false;
globalThis.BroadcastChannel = class extends Channel {
constructor(name) { super(name); this.identityChannel = name === 'sp.identity'; }
postMessage(message) {
super.postMessage(message);
if (this.identityChannel && message.type === 'who') hold = true;
}
};
globalThis.setTimeout = (fn, ms, ...args) => {
if (hold) {
hold = false;
globalThis.finishIdentityInit = () => fn(...args);
return 0;
}
return timer(fn, ms, ...args);
};
});
await owner.reload({ waitUntil: 'domcontentloaded' });
await owner.waitForFunction(() => typeof globalThis.finishIdentityInit === 'function');
await guest.bringToFront();
await guest.click('[data-testid="resume-local-match"]');
await guest.waitForSelector('.modal .btn--block');
await guest.click('.modal .btn--block');
await guest.waitForFunction(() => document.body.textContent.includes('此对局仍在其他窗口中'));
assert.equal(await owner.evaluate(() => sessionStorage.getItem('sp.token')), before.token);
await owner.evaluate(() => globalThis.finishIdentityInit());
await owner.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await owner.evaluate(() => {
const s = globalThis.__SP__.store.get();
return { id: s.me.playerId, code: s.room.code, token: sessionStorage.getItem('sp.token') };
});
assert.deepEqual(after, before);
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().me.playerId), guestId);
for (const page of [guest, owner]) {
const pong = await page.evaluate(() => globalThis.__SP__.net.request('ping', { c: Date.now() }));
assert.equal(pong.t, 'pong');
assert.equal(await page.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
}
} finally { await ctx.close(); }
});
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {