diff --git a/docs/BALANCE.md b/docs/BALANCE.md index 5edb42da..c88766a6 100644 --- a/docs/BALANCE.md +++ b/docs/BALANCE.md @@ -393,12 +393,13 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms. * Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2) also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round - preview, and the boss battle uses up one of their battles (kill coins → pending funds for the Hidden Core prep). + preview, and the boss battle uses up one of their battles (kill coins, and a perfect-payout 战术特训 card's coins when the player's own field is perfect and the team wins → pending funds for the Hidden Core prep). * A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned). * 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 03966198..f12b6a69 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 @@ -275,7 +276,9 @@ its enemies; an exposure model (time on each tile × DPS against the round's arm unit's cover is the range it is deployed with under the player's loadout, `rangeRec` = `attackRangeGrid`, the grid the server's `summonRange` and the card use —, blocker hold time, anti-air only on flying routes) is maximized greedily (blockers, then damage dealers, then healers) over the server's deploy map (no 深水区, PlayerState.deployMap); a -tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player +blocker whose range is its own tile only (range 0-1, the one grid (0,0): 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱, 塞雷娅, 余) takes a free +tile of the enemy road first — it blocks and hits nothing off it — and only when the road has none free any legal tile (0.2.2, PR #339 by +@IceCodeNew); a tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player report #9 after 0.1.0), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads `board.js legalTiles` on the deploy map; `g.move` is the judge). Boss rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the own board (rows −7, the right player @@ -425,6 +428,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 +462,47 @@ 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 — the copied items arrive unequipped in the hand, PRTS 画卷 备注 "获得的装备为未装备状态"), 教鞭 / “神秘顾客” (a random bounty is added). +items — the copied items arrive unequipped in the hand, PRTS 画卷 备注 "获得的装备为未装备状态"), 教鞭 (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 or a human under AI 托管 — picks like the bots do; a seat that + left is eliminated (`onLeave` → `eliminate`), which drops its open offer: nobody picks for it. +* **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 @@ -619,7 +650,16 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o `enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault / Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of - the bounty's battles and its kill coins go to pending funds. + the bounty's battles and its kill coins go to pending funds — with, for every perfect-payout card (战术特训), its coins + as after a normal battle ("若各自行动阶段就达成完美作战,获得N资金"): once per player, when the team won (the first end the + server registered: the leader's pool empty), the result is a real one (not the stand-in of a field that never + reported) and the player's own field let no counted enemy through [ASSUMED: the text has no boss-battle exception, and + the card's enemies do come in the boss battle] — and, for a field a client reported (the boss path never re-simulates + it), the result agrees with what its authority reported while it fought: LP it asked the team to pay must have been + split in `b.progress.leaksBy` (per player, the enemy leaks; the rest is the leader's own "扣除目标生命" effects), and a + player's leaked list must show at least its reported leak LP (`Match._bossLeaksAgree`), so a result cannot hide a charged + leak or hand it to the other seat; a server-run field is the truth itself. They are spent in the Hidden Core's prep like + the kill coins. * **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2× battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`, default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 29ac54d0..e40abc00 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -49,7 +49,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 afae1825..71692472 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 b05219fd..67e1dcc1 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: random order (「AI 队友最后选择」, off by default: humans first, the same partition as the strategy draft), 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,7 +89,10 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh ### 6.6 AI players (bot.js) -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). +Pending Pointing Stick offers use the existing bounty scorer at bot prep entry, immediately after a successful cast, +and before Ready. The prep's deadline — and the scheduler's fallback when a step of a bot's prep fails — randomly resolves any remaining offer before temp and Ready; a seat that left is eliminated and its offer dropped. + +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 blocker whose range is its own tile only — range 0-1 — takes a free tile of the enemy road first; 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 d3f48ca0..5c4837f8 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, 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. ### 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...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `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). @@ -41,7 +41,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, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354) rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2 @@ -83,7 +84,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { **Protocol (additions; `shared/protocol.js` is normative).** - `battleId` = `...` (`Match._ccField`; `` = the match seed in base 36 plus `-`, the room's match number the lobby passes as `opts.matchNo`; `` counts every battle of the match; the informational `.` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle. - S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up). -- C→S `b.progress { battleId, gt, killed, total, resolved?, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). `resolved` = the HUD capsule's numerator of this field (below; omitted by a client that cannot count it — the teammate UI then falls back to `killed`); `killed` / `total` are the field's own counters: `killed` counts every `counted` knock-out (a runtime split / summon included) and may exceed `total`, which counts only the enemies the round scheduled. Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast). +- C→S `b.progress { battleId, gt, killed, total, resolved?, leaks?, left?, bossDmg?, by?, leaksBy?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). `resolved` = the HUD capsule's numerator of this field (below; omitted by a client that cannot count it — the teammate UI then falls back to `killed`); `killed` / `total` are the field's own counters: `killed` counts every `counted` knock-out (a runtime split / summon included) and may exceed `total`, which counts only the enemies the round scheduled. Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`), `leaksBy` = its split per player (the LP the enemies that reached that player's goal cost: every leak entry the result will show × its `lpr`; `leaks` minus its sum is the leader's effects; `spec.js battleProgress`) — the server keeps the highest value per player and, before a perfect-payout bounty pays in a client-reported field, requires the result's leaked list of that player to show at least that much and the LP asked for to have been split at all (`Match._bossLeaksAgree`, the boss path does not re-simulate). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast). - C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`). - S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary. - S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below. 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 a26cd9de..8e33a422 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -104,7 +104,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; @@ -181,7 +181,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: [ @@ -680,7 +680,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'); @@ -688,6 +697,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'); @@ -695,6 +713,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 60f49313..996769ae 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -386,6 +386,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 c7165157..8025dbb8 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -389,6 +389,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階", "选择一项": "1つ選択", "倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます", + "教鞭选择": "教鞭の選択", + "教鞭 · 战术特训": "教鞭 · 戦術訓練", + "请选择一项战术特训": "戦術訓練を1つ選択してください", + "请先完成教鞭选择": "先に教鞭の選択を完了してください", + "休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます", + "从候选中选择一项战术特训": "候補から戦術訓練を1つ選択", "选择一项(无时间限制)": "1つ選択(時間制限なし)", " · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定", "已完成选择": "選択完了", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index b9d0a2a8..e4b310cc 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -385,6 +385,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계", "选择一项": "하나를 선택하세요", "倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다", + "教鞭选择": "교편 선택", + "教鞭 · 战术特训": "교편 · 전술 특훈", + "请选择一项战术特训": "전술 특훈 1개를 선택해 주세요", + "请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요", + "休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다", + "从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택", "选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)", " · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인", "已完成选择": "선택 완료", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 8d9f00df..2f3a4bec 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -387,6 +387,12 @@ "CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段", "选择一项": "選擇一項", "倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配", + "教鞭选择": "教鞭選擇", + "教鞭 · 战术特训": "教鞭 · 戰術特訓", + "请选择一项战术特训": "請選擇一項戰術特訓", + "请先完成教鞭选择": "請先完成教鞭選擇", + "休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定", + "从候选中选择一项战术特训": "從候選中選擇一項戰術特訓", "选择一项(无时间限制)": "選擇一項(無時間限制)", " · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認", "已完成选择": "已完成選擇", diff --git a/public/js/battle/runner.js b/public/js/battle/runner.js index 1d206ee0..35965d6b 100644 --- a/public/js/battle/runner.js +++ b/public/js/battle/runner.js @@ -448,6 +448,9 @@ export function createBattleRunner(deps) { if (bossLike(e)) { const pool = e.battle.sharedBoss; msg.leaks = Math.min(1e6, e.meter.lp); + // the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's + // leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree) + if (p.leaksBy) msg.leaksBy = p.leaksBy; msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0; if (pool && pool.byPlayer) { const by = {}; diff --git a/public/js/screens/game.js b/public/js/screens/game.js index b26ad5f1..ced277f8 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, deviceInfo, deviceTipAt, noteDeviceUnits, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, @@ -197,6 +197,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) @@ -220,7 +221,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; @@ -284,7 +287,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 @@ -673,6 +676,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) @@ -1199,6 +1213,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); @@ -1301,7 +1323,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 = () => { @@ -1369,7 +1391,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} @@ -1465,6 +1487,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`