diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 4fbac26d..c88766a6 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -398,7 +398,8 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms. * docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key). * 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports `rule: 'min'` among the players still alive). -* 教鞭 / “神秘顾客” stay a random bounty, documented in docs/META.md §2.5. +* 教鞭 offers a personal choice of three 战术特训 cards (a bot picks by the bounty scorer, the prep's deadline at random); + “神秘顾客” stays a random bounty — docs/META.md §2.5. --- diff --git a/docs/META.md b/docs/META.md index d4031210..795cde6c 100644 --- a/docs/META.md +++ b/docs/META.md @@ -175,18 +175,18 @@ A `choice:` registry handler overrides the default application (§2.4) ### 1.3 Disconnects, AI takeover * Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at - the deadline (the temp pieces due at that prep sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server + 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 resumable for 10 min (net.js `reconnectWindowMs`); a **solo** run's for the official `constants.singleReconnectTime` (86 400 s = 24 h, lobby.js `soloResumeWindowMs`) — nothing in a solo run is timed, so it simply waits. `onReconnect` resends `m.public`, `m.private` and the `b.start` of the field the player is on / watching. -* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off. +* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, an open 教鞭 choice, ready) until turned off. * `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 / §10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final Assault pairing (re-planned when it quits before the boss fight) and the boss pool (bloodPoint per player alive when the fight starts — a departed seat no longer counts, DESIGN §25.13.4); its running normal battle is force-ended; a pending band pick - becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived. + becomes the default band, a 机变 turn passes on and an open 教鞭 choice goes with the seat (`eliminate`). Status `left`, LP 0, rounds passed = the rounds it had survived. When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are left it ends as `'eliminated'`. @@ -263,7 +263,8 @@ like any owned unit — AI 托管 too; the buy loop's bench shed, which sells by never sells a piece gained since the bot's last prep ended, `rememberOwned`), 身份牌 / 通讯机 / 寻呼模块 on a focus member; 画卷 copies the most valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops -a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never. 机变: a +a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never; 教鞭's three +cards are scored like a 机变 bounty draft (§2.5). 机变: a bounty card is scored by its expected payout minus its expected leaks × the value of an LP (2 + 20 / LP), the kill chance from the exposure model below for that one enemy against the own board as it stands (its HP × the round's multiplier, DEF / RES, speed, route kind) — an expected-value comparison: it prefers the card with the best payout @@ -425,6 +426,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati | `setDeviceActive(alias, on)` / `setTileOverride(r, c, 'melee'\|'ranged'\|'none')` | terrain changes of this player's board (legality + battle input `deviceOverrides`) | | `addEffect(ref)` / `removeEffect(id)` / `setEffectCounter(id, v)` | EffectRefs `{ id, key?, name, desc, iconKind, iconId, counter?, battle=true, params?, data?, hidden? }` — shown in `m.private.effects`, passed to battles as `playerEffects` when `battle` | | `addBounty(card)` | a bounty (choices.json cards.bounty shape) on the next battles | +| `offerBountyChoice(cards, sourceItemId)` | 教鞭 only: open a PERSONAL choice of up to three of `cards` (cards.bounty entries) for this player — shuffled once with the meta rng, kept in `m.private.personalChoice` until the owner confirms one (`g.choice { idx, choiceId }`) or the prep's deadline / an AI seat picks (§2.5); returns the intent result (`{ ok }`, or `{ error, detail }` for a phase that is not PREP, a Ready player, a choice already open or no card — before any rng draw or id, so the Art stays in the hand) | | `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages | | `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) | @@ -458,20 +460,46 @@ bilibili BV1vzyVBuEN9 ≈ 8:24 / BV1Qkw1zMEoR ≈ 7:25, pointed out in PR #2 — is not consumed: PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)", PRTS 卫戍协议/帮助 "佩戴的 装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)…时自动卸除", players re-inject it every round; player feedback after 0.1.0), 画卷 (copy the operator in range with its -items), 教鞭 / “神秘顾客” (a random bounty is added). +items), 教鞭 (a personal choice of three 战术特训 cards), “神秘顾客” (a random bounty is added). -**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice -("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content -(server/sim/content/items/meta.js) draws 3 cards and takes one at random — 教鞭 from the 20 战术特训 cards (payout -`perfect`: extra enemies, the card's coins when the own phase is perfect; PRTS 下半 记录 §法术 教鞭 "于3个战术特训的悬赏 -任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", 杜宾 加练! "若自身战斗完美作战可获得资金"; user playtest #6 -item 4 review), “神秘顾客” (granted by nothing in act2) from the band-bounty family `enemyeffect_b_*` [ASSUMED] — each -limited to enemies the mode can field (the built-in fallback: a random tier ≤ II bounty of `cards.bounty`), and adds it -with `ctx.addBounty`. A personal choice overlay would need its own phase / protocol message outside SP_DRAFT (a -second, simultaneous draft in co-op, with timers and AI takeover) for a rarely used Art, while the random pick keeps the -risk / reward the item is about. The -bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1 -fund, the Art passes to the next alive player) is content too (`onDestroy`). +**教鞭 offers a personal choice of three 战术特训 cards (0.2.2); “神秘顾客” stays a random bounty.** The official Arts open a +personal 悬赏 choice ("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`, choiceType `PERSONAL_CHOOSE`). 教鞭 +(PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项"; §机变阶段 "※以下悬赏任务仅由法术教鞭生成"; 杜宾 +加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金"; user playtest #6 item 4 review) offers three of +the 20 战术特训 cards (payout `perfect`: extra enemies, the card's coins when the own phase is perfect), each limited to +enemies the mode can field. Until 0.2.1 the server took one of the three at random — a deliberate simplification: a +personal choice looked like it needed a phase of its own, a protocol message outside SP_DRAFT, a timer and an AI +takeover for a rarely used Art. The owner decided on 2026-10-08 to follow the official choice (PR #353 by @Sukvii), and +it fits in the existing parts: + +* **Offer.** Content (server/sim/content/items/meta.js) calls `ctx.offerBountyChoice(cards, itemId)` (`Match.offerBountyChoice`, + match/spDraft.js): the eligible cards are shuffled once with the meta rng and up to three different ones are kept in + `ps.personalChoice { id, round, sourceItemId, cards }`. The Art is used up (and counts for the two per round) when the + offer is made; nothing is applied yet. Refused — the Art stays in the hand, no rng draw, no id — outside the player's + PREP, for a Ready player, while an offer is already open and when no card is left to offer. +* **Private.** Only the owner sees it: `m.private.personalChoice` (re-sent on a reconnect or a refresh, so the same cards + and id come back); never `m.public`, a scout's view or a spectator. Players have offers of their own, at the same time. +* **Confirm.** `g.choice { idx, choiceId }` (`Match.pickPersonalChoice`) validates the owner, the id, the round and the + index, then adds the card with `addBounty` and closes the offer; a request without a `choiceId` is the public 机变 draft + as ever, and a stale, foreign or repeated id never falls through to it (`BAD_TARGET`). The client shows the overlay of + the 机变 draft (`ChoiceOverlay`, `personal`: the same two taps, no order, no turns) over a locked prep. +* **No phase, no timer.** The offer lives inside PREP. Ready is refused while it is open (`setReady`: `BAD_TARGET`; + `m.private.canReady` false), and the prep's own deadline resolves it: in a timed co-op prep `prepDeadline` takes one of + the cards at random (the meta rng, like a 机变 turn that runs out) before the temp pieces and Ready; a solo or + single-human prep is untimed and has no timer for it, and a disconnected player waits for the deadline like for any + other prep action. A seat the engine plays — an AI teammate, a human under AI 托管, one that left — picks like the bots do. +* **The bots.** `bot.js` scores the three cards like a 机变 bounty draft (`botPickCard` → `bountyScore`: the expected + payout minus the expected leaks × the value of an LP, against the own board; the bots' seeded rng only breaks ties), so + the choice is deterministic for a seed: at the start of the bot's prep (an offer left by a human who turned 托管 on), + right after it cast the Art, and before Ready. When a step of the bot's prep fails, the scheduler's fallback readies the + seat with the random pick instead — no seat is ever left un-ready by an offer. +* The chosen bounty is a bounty like any other: next battles (a multi-round card lasts two, §1.2), 联防 payouts, the Final + Assault / Hidden Core spawns — and in the boss rounds its perfect-battle coins (§3 Waves). + +“神秘顾客” (granted by nothing in act2, no PRTS list of its cards) remains random: three cards are drawn from the band-bounty +family `enemyeffect_b_*` [ASSUMED], limited to enemies the mode can field, and one is added with `ctx.addBounty` (the +built-in fallback: a random tier ≤ II bounty of `cards.bounty`). Its destroy clause (+1 fund, the Art passes to the next +alive player) is content too (`onDestroy`). EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备), `effect:builtin_next_buy_elite` (升华). An eliminated player gets no dispatch, except an EffectRef whose handler sets `afterElimination: true` (its `onRoundStart` still runs: `EffectDispatcher.dispatchEliminated`, called by diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..48753eaf 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -46,7 +46,7 @@ | 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 | | 刷新 | 1 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | -| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 | +| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次。**教鞭**(杜宾的策略每 2 个回合给一枚)拖到场上使用后即销毁,休整期里弹出 3 张战术特训悬赏(只有你自己看得到,刷新页面或断线重连后还是这 3 张),点一张再点一次确认,选中的那张成为接下来的悬赏;选定之前不能点「准备就绪」。同盟模式的休整期倒计时结束时还没选,会随机选一张;独立模拟和只有一名玩家时休整期不限时,所以不会自动选。AI 队友和 AI 托管的座位按悬赏评分自己选。战术特训的赏金要「各自行动阶段就达成完美作战」才给(最终攻势、隐秘核心里是你这一侧没有漏怪且全队获胜);“神秘顾客”仍然随机给一个悬赏 | | 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 | | 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | diff --git a/docs/design/engine.md b/docs/design/engine.md index 9b90dee1..97f27166 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -177,6 +177,6 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies - items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos. - bands: 40 strategies (meta + battle). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5); the swaps carry the spawn tag `'duck'`, and the swapped 流泪小子 hides again 1 s after a block ends, not the official 0 s (the owner's decision of 2026-10-07, §25.22.6). - enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain. -- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken). +- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). Pointing Stick (教鞭) offers up to three private Tactical Training cards during PREP; the owner confirms one through `g.choice` with its `choiceId`, with Ready blocked until confirmation or the existing prep deadline's auto-pick. “神秘顾客” remains random (META §2.5). --- diff --git a/docs/design/match.md b/docs/design/match.md index 9e4338b0..cb064e57 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -14,7 +14,8 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed [SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed; a card is picked with two taps, §18.2) PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready) - PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline + PREP_END (<休整期结束时> effects; unfrozen shop cleared; Ready is blocked while a personal choice or temp overflow is pending; + at the deadline personal choices are auto-picked first, then the temp pieces due at this prep are resolved (chess sold back, items destroyed, summon stacks removed — they come back at the next round start, §20.1) — pieces that arrived after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue)) COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause) @@ -44,6 +45,7 @@ Solo FUNNY has 9 rounds (boss at R9); everything is read from `data/config.json board: Map<'r,c', Piece>, // chess & token pieces on the normal field bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters + personalChoice: null | { id, round, sourceItemId, cards: [ BountyCard ] }, // private PREP offer; raw cards on the server counters: { refreshes, buys, sells, … }, stats: { … for results } } Slot = { kind:'chess'|'item', id, price, sold:false, frozen } @@ -62,6 +64,12 @@ Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (inco **Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5). +Pointing Stick creates at most one pending offer per alive, non-Ready player in PREP. The owner confirms it with +`g.choice { idx, choiceId }`; confirmation validates its ID, round and index, adds one bounty and clears the offer. +Ready requires no pending offer as well as an empty temp area. The co-op prep deadline resolves the offer randomly +before resolving temp and setting Ready; untimed solo / single-human prep does not schedule a separate choice timer. +The offer is recipient-private, including after reconnect, and never enters `m.public.sp` or a teammate's scout view. + ### 6.3 Bonds & layers (bondsMeta.js) - `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count that has a real member on the board (the views mark such an entry `harmony: 1`, §21.26), etc. (research 02). @@ -81,6 +89,9 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh ### 6.6 AI players (bot.js) +Pending Pointing Stick offers use the existing bounty scorer at bot prep entry, immediately after a successful cast, +and before Ready. The timer / disconnected-seat finalizer randomly resolves any remaining offer before temp and Ready. + Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time). Under AI 托管 a human's own 自选 pieces score `DIY_PIECE_BONUS` (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (`levelUp`, `diyOpensAt`; 0.2.0 — bots field no 自选 piece). --- diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..0dcb3bf5 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -15,7 +15,7 @@ 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) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. 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. ### 8.2 Match -C→S (all carry `rid`): `g.infoReady` · `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}` · `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` +C→S (all carry `rid`): `g.infoReady` · `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: - `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112) - `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51). @@ -34,7 +34,8 @@ S→C: `m.private`: ```js -{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */, +{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while a personal choice or temp overflow is pending */, + personalChoice: null | { id, round, sourceItemId, cards: [ { kind: 'bounty', id, name, desc, descRaw, ... } ] }, // recipient only, including reconnect; absent from public / scout views shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes, frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ], rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2 diff --git a/docs/history/v2.5-playtest6.md b/docs/history/v2.5-playtest6.md index 913b5a9a..c87f4a36 100644 --- a/docs/history/v2.5-playtest6.md +++ b/docs/history/v2.5-playtest6.md @@ -103,6 +103,8 @@ Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB - **#19**: PRTS 帮助 "临时刷新3名高于调度中心等级1阶(最多6阶)的干员"; the official 玩法介绍 image shows three different operators at price 0. v2.4.1 rolled three times independently (14–21 % of offers had a duplicate). **Now** three different chess, copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED]; the offer reserves no copies. The integration QA found the same repeat in the other free pick-one chess offers — 信标 (same tier), 寻呼模块 (same bond ≤ shop level, content/items/meta.js) and the engine default `builtinMeta use_equip_reward_special_goods_char_chess` — they now draw different operators too (信标 tops up from the tier below, 寻呼模块 shows fewer cards when the pool has no other one), and `pushRewardOffer` drops a repeated id from any list it is given (`test/content/items.test.js`). - Tests: `test/match/playtest6-matchflow.test.js` (the draft, two battles for a multi-round card end to end, 联防 counts, rewards), `test/match/followups.test.js`, `test/ui/playtest6-unite.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/items.test.js` (教鞭). +Superseded in 0.2.2: 教鞭 no longer takes one of its three 战术特训 cards at random — its owner chooses (docs/META.md §2.5). + ### 20.7 机变 card text; the shop's confirming tap on phones (#6, #10) — `ui/choiceOverlay.js`, `css/screens/game-panels.css`, `ui/shopBar.js`, `css/screens/game-shop.css`, `css/devices.css` - **#6 seen** (screenshot 2, Android 756×366 CSS px, 终极 co-op): the 道具补给 cards showed only icon, name and tier. **Cause**: the card was a centred column sized for 16:9; with the root font clamped at 40 px a 3×2 card was 105 px tall and showed 0.2 lines of text (217 of 223 texts cut), in all four families; at 1920×1080 nine long texts were cut at 4 lines. **Official** (research 06 §11.5; screenshots Mb2mtd9 / Wrkhtxv / SEbySVl): a framed icon at the top-left with the bold name beside it, the description below, numbers in blue, the tier chip on the icon's corner, the taker's round avatar alone at the top-right. **Now**: that layout; the text from `cardText` (the card's own `descRaw`, else the data `descRaw` while its plain form equals the server's `desc`, else the server text), clamped to 7 lines (8 at line height 1.3 on phones under 700 px wide: with Noto Sans SC — the page's web font, metrics of Android's CJK font — 海沟实验体 wraps to 8 lines at 640×360, residual QA); short screens get a tighter overlay header (no CONTINGENCY micro line) and .19rem text; the taker badge is the avatar alone (name in its tooltip and the card's aria-label) and the taken card's head reserves its width. All 223 texts (51 items, 129 bounty, 43 tactic) fit in full, taken or not, from 640×360 to 2560×1080, measured with Noto Sans SC. diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js index 0c4f218b..edb6f9e4 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -101,7 +101,7 @@ function refreshPrivate() { const p = S.priv; p.deployCount = p.board.filter((x) => x.kind === 'chess').length; p.bonds = computeBonds(p); - p.canReady = !p.temp.some(Boolean); + p.canReady = !p.personalChoice && !p.temp.some(Boolean); const upg = [5, 8, 11, 12, 13]; p.shop.maxLevel = 6; if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0; @@ -178,7 +178,7 @@ function buildState() { ].sort((a, b) => a.t - b.t); const priv = { - playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true, + playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true, personalChoice: null, shop: { level, maxLevel: 6, upgradePrice: 9, refreshPrice: 1, freeRefreshes: 0, frozen: VARIANTS.has('frozen'), slots, rewardOffer: null }, hand, temp, board, deployCap: 8, deployCount: 0, bonds: [], effects: [ @@ -661,7 +661,16 @@ async function mockRequest(t, f = {}) { } pushPublic(); return {}; } - case 'g.art': { prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; } + case 'g.art': { + prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); + if (it.piece.id === 'chess_item_6_03_m') { + if (p.personalChoice) fail('BAD_TARGET'); + const cards = data.get('choices').cards.bounty.filter((c) => c.payout === 'perfect' && c.rounds < 90).slice(0, 3) + .map((c) => ({ ...c, kind: 'bounty', id: c.effectId, descRaw: data.lookup('effects', c.effectId)?.descRaw })); + p.personalChoice = { id: `mock.choice.${nextUid()}`, round: pub.round, sourceItemId: it.piece.id, cards }; + } + removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; + } case 'g.reward': { if (!p.shop.rewardOffer) fail('WRONG_PHASE'); const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET'); @@ -669,6 +678,15 @@ async function mockRequest(t, f = {}) { p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {}; } case 'g.choice': { + if (f.choiceId !== undefined) { + prepOnly(); + const choice = p.personalChoice; + if (!choice || choice.id !== f.choiceId || choice.round !== pub.round || !choice.cards[f.idx]) fail('BAD_TARGET'); + const card = choice.cards[f.idx]; + p.effects.push({ id: choice.id, name: card.name, desc: card.descRaw || card.desc, iconKind: 'choice', iconId: card.id }); + p.personalChoice = null; + refreshPrivate(); return {}; + } if (!pub.sp) fail('WRONG_PHASE'); const taken = Object.values(pub.sp.picks).includes(f.idx); if (taken) fail('BAD_TARGET'); @@ -676,6 +694,7 @@ async function mockRequest(t, f = {}) { } case 'g.ready': { if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE'); + if (f.ready && p.personalChoice) fail('BAD_TARGET'); if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY'); p.ready = !!f.ready; refreshPrivate(); return {}; } diff --git a/public/i18n/en.json b/public/i18n/en.json index bbf29fcf..771aec9a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -382,6 +382,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase", "选择一项": "Choose one", "倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically", + "教鞭选择": "Pointing Stick Choice", + "教鞭 · 战术特训": "Pointing Stick · Tactical Training", + "请选择一项战术特训": "Choose a Tactical Training bounty", + "请先完成教鞭选择": "Complete the Pointing Stick choice first", + "休整期结束时未选择将自动选定": "An unselected bounty will be picked automatically when the Rest Phase ends", + "从候选中选择一项战术特训": "Choose one Tactical Training bounty from the offered cards", "选择一项(无时间限制)": "Choose one (no time limit)", " · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm", "已完成选择": "Choice made", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 9986bbbe..fac957f3 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -385,6 +385,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階", "选择一项": "1つ選択", "倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます", + "教鞭选择": "教鞭の選択", + "教鞭 · 战术特训": "教鞭 · 戦術訓練", + "请选择一项战术特训": "戦術訓練を1つ選択してください", + "请先完成教鞭选择": "先に教鞭の選択を完了してください", + "休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます", + "从候选中选择一项战术特训": "候補から戦術訓練を1つ選択", "选择一项(无时间限制)": "1つ選択(時間制限なし)", " · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定", "已完成选择": "選択完了", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index daf09d7f..7435d3f5 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -381,6 +381,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계", "选择一项": "하나를 선택하세요", "倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다", + "教鞭选择": "교편 선택", + "教鞭 · 战术特训": "교편 · 전술 특훈", + "请选择一项战术特训": "전술 특훈 1개를 선택해 주세요", + "请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요", + "休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다", + "从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택", "选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)", " · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인", "已完成选择": "선택 완료", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 24d9f4e9..13ff8596 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -383,6 +383,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段", "选择一项": "選擇一項", "倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配", + "教鞭选择": "教鞭選擇", + "教鞭 · 战术特训": "教鞭 · 戰術特訓", + "请选择一项战术特训": "請選擇一項戰術特訓", + "请先完成教鞭选择": "請先完成教鞭選擇", + "休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定", + "从候选中选择一项战术特训": "從候選中選擇一項戰術特訓", "选择一项(无时间限制)": "選擇一項(無時間限制)", " · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認", "已完成选择": "已完成選擇", diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..8eb5d490 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -95,7 +95,7 @@ import { pieceTile } from '../render/drag.js'; import { phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, battleOverSfx, uniteResultBox, battleResultBox, - snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, + snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers, terrainInfo, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, @@ -184,6 +184,7 @@ function MatchScreen() { const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE const [readyBusy, setReadyBusy] = useState(false); const [spBusy, setSpBusy] = useState(null); + const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R' const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2) const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera) @@ -207,7 +208,9 @@ function MatchScreen() { const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null; const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null; const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null; - const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther; + const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null; + const hasPersonalChoice = !!personalChoice; + const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice; const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther; const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase); const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null; @@ -271,7 +274,7 @@ function MatchScreen() { getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups, }), [priv, pub?.stageId, editable, gd.ready, deployField]); - live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; + live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; // ---- camera: every request goes through setCam, which remembers it for the pen's way back ----------------------- // the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field @@ -637,6 +640,17 @@ function MatchScreen() { setSpBusy(null); }, [phaseKey]); + useEffect(() => { + setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null); + if (!personalChoice) return; + setSel(null); + setDetail(null); + setArmedCard(null); + setDrag(null); + view?.highlightTiles(null, null); + cancelFacingRef.current(); + }, [personalChoice?.id, view]); + // a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends // the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own // row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch) @@ -1154,6 +1168,14 @@ function MatchScreen() { const L = live.current; // dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return; + if (L.hasPersonalChoice) { + if (act !== 'escape') e.preventDefault(); + if (act === 'ready') { + audio.sfx('error', { volume: 0.5 }); + toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn'); + } + return; // ChoiceOverlay handles Escape without cancelling the server's pending choice. + } if (act === 'escape') { if (L.emoteOpen) setEmoteOpen(false); else if (L.pen && !L.detail) togglePenRef.current(false); @@ -1256,7 +1278,7 @@ function MatchScreen() { const temp = tempInfo(priv); const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp; // the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line - const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0; + const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0); // the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone), // and the root font size (1rem) const panelFrame = () => { @@ -1307,7 +1329,7 @@ function MatchScreen() { // bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup const offBonds = modeOffBonds(getMode(pub?.modeId)); - return html`
e.preventDefault()}>
${viewKind === 'loading' ? html`
<${Spinner} label="LOADING FIELD" />
` : null} @@ -1402,6 +1424,14 @@ function MatchScreen() { ${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total} onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null} + ${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true} + busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total} + onPick=${async (i) => { + const choiceId = personalChoice.id; + setPersonalBusy({ choiceId, idx: i }); + await actions.choice(i, choiceId); + setPersonalBusy((b) => b?.choiceId === choiceId ? null : b); + }} />` : null} ${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro} tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null} diff --git a/public/js/ui/choiceOverlay.js b/public/js/ui/choiceOverlay.js index 11207e84..e20d9ae3 100644 --- a/public/js/ui/choiceOverlay.js +++ b/public/js/ui/choiceOverlay.js @@ -127,7 +127,7 @@ export function armedCard(armed, sp, o) { /** * The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice). - * @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props + * @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props */ export function ChoiceOverlay(props) { const { sp, myId, solo, busyIdx = null, onPick } = props; @@ -154,10 +154,10 @@ export function ChoiceOverlay(props) { /** * The overlay's view (pure: no hooks — test/ui renders it as a function). - * @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null, + * @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null, * onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props */ -export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) { +export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) { if (!sp) return null; const fam = data.get('choices')?.families?.[sp.family] || null; const rawFam = data.getRaw('choices')?.families?.[sp.family] || null; @@ -166,8 +166,8 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, const mine = sp.pickOf.get(myId); const turnName = players.get(sp.turnPid)?.name || t('队友'); const special = /_s$/.test(String(sp.family || '')); - const order = solo ? [] : sp.order; - const timed = !solo && !sp.untimed; + const order = personal || solo ? [] : sp.order; + const timed = personal ? pub?.deadline > 0 : !solo && !sp.untimed; const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null; const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null; // a press anywhere but a card or the confirm button drops the selection @@ -177,17 +177,18 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return; onDisarm(); }; - return html`