feedback3: merge W3C — the beacon keeps an elite and survives the sender's elimination (#6, GitHub #86); 远见 150 can bring 至简 to 0; ready asks when funds are left (#4); the host can kick a human before the match (#17)
This commit is contained in:
commit
d6e457f90c
23 files changed
+541
-45
No files matched your search
@@ -118,7 +118,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
|
||||
### 和朋友一起玩(局域网)
|
||||
|
||||
1. 打开页面 → 输入昵称 → **同盟模拟** → 创建房间。房主选择难度,可以添加 / 移除 AI 队友。
|
||||
1. 打开页面 → 输入昵称 → **同盟模拟** → 创建房间。房主选择难度,可以添加 / 移除 AI 队友;开始前也可以把其他博士移出房间(对方可凭密钥重新加入)。
|
||||
2. 把 4 位字母的**同盟密钥**,或「复制链接」得到的 `http://<地址>:3000/?room=密钥` 发给朋友。
|
||||
3. 所有人点「准备就绪」后房主开始。
|
||||
4. 同一 Wi-Fi / 路由器下的朋友打开启动窗口里列出的地址(形如 `http://192.168.x.x:3000`)即可。打不开时多半是防火墙:Windows 首次启动时在弹窗中允许「专用网络」,或运行 `npm run doctor` 查看具体命令;访客 Wi-Fi 常开启「AP 隔离」,也会导致连不上。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
|
||||
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
|
||||
+4
-4
@@ -407,10 +407,10 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
||||
|
||||
### 8.1 Session & lobby
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `ping {c}`
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `ping {c}`
|
||||
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
|
||||
|
||||
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
||||
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
||||
|
||||
### 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}` · `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`
|
||||
@@ -822,8 +822,8 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in
|
||||
|
||||
- **What the user saw**: 至简 (Ⅲ) cost 2 资金; the user remembered 1. His 特质 garrison_13 reads 购买价格为1, but `bb.price` is 2, and the handler set the price to `bb.price` ("blackboards are authoritative").
|
||||
- **Official**: two chess carry this 特质 (`effectType` SERVER_CHESS_PRICE) and both texts say 购买价格为1 — 至简 (Ⅲ, tier price 3, `price` 2) and 红豆 (Ⅰ, tier price 2, `price` 1, hidden in 下半). Only "`bb.price` is the discount off the tier price" gives the text's 1 for both (3 − 2, 2 − 1).
|
||||
- **Change**: the handler lowers the price by `bb.price` (`ctx.modifyPrice(-p)`), and `EffectDispatcher.dispatch` runs the priced chess's own 特质 first on `onPrice` (before globals / band / bonds), so 远见's discount (−1, never below 1) and strategy caps act on the lowered price: 至简 stays at 1 with 远见 150 instead of dropping to 0. The shop card keeps `basePrice` 3, so the price shows in the discount tone. docs/META.md §2.2 / §2.3 say the same.
|
||||
- Tests: `test/content/garrisons_meta.test.js` (every SERVER_CHESS_PRICE owner: tier price − `bb.price` = the text's price = `priceOf`; buying 至简 costs 1; a global modifier already sees 1; 远见 150 leaves 至简 at 1).
|
||||
- **Change**: the handler lowers the price by `bb.price` (`ctx.modifyPrice(-p)`), and `EffectDispatcher.dispatch` runs the priced chess's own 特质 first on `onPrice` (before globals / band / bonds), so 远见's discount and strategy caps act on the lowered price: 至简 stayed at 1 with 远见 150 (a floor of 1 then assumed for 远见; since 0.1.3 the −1 has no floor but 0, so 至简 drops to 0 — owner's decision 2026-10-04). The shop card keeps `basePrice` 3, so the price shows in the discount tone. docs/META.md §2.2 / §2.3 say the same.
|
||||
- Tests: `test/content/garrisons_meta.test.js` (every SERVER_CHESS_PRICE owner: tier price − `bb.price` = the text's price = `priceOf`; buying 至简 costs 1; a global modifier already sees 1; 远见 150 takes 至简 to 0 since 0.1.3).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-2
@@ -430,7 +430,8 @@ a promotion or `ctx.destroyPiece` returns) ends with the auto-merge (`acquireIte
|
||||
Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds),
|
||||
紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶
|
||||
(+funds next round), 人事部文档 (cap 9), 博士投影 (elite now / at the next round start), 寻呼模块 / 信标 (pick-one
|
||||
offers; 信标 gifts the original chess to the teammate with the most members of its bonds next round), 商业包装方案 (every
|
||||
offers; 信标 gifts the original chess — an elite stays an elite — to the teammate with the most members of its bonds next
|
||||
round, also when the sender was eliminated meanwhile; a failed grant waits for the next round start), 商业包装方案 (every
|
||||
N sells → same-bond chess), 突变细胞 (after battle the carrier — deployed or on the bench — is destroyed, its tile freed;
|
||||
its equipment, the cell included, returns to the hand first; then a random NORMAL chess one tier higher, max 6, is
|
||||
gained like any gained operator: into the 整备区, overflow temp, never onto the carrier's tile — official footage,
|
||||
@@ -453,7 +454,9 @@ 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`).
|
||||
EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备),
|
||||
`effect:builtin_next_buy_elite` (升华).
|
||||
`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
|
||||
`Match.startRound`) — only `effect:builtin_gift` does, so a 信标 gift reaches the teammate (GitHub #86).
|
||||
|
||||
### 2.6 机变 card application
|
||||
`choice:<effectId>` handler (content) → else the family default (choices.js): bounty → `ctx.addBounty`; supply/shop →
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
| 项目 | 数值 |
|
||||
|---|---|
|
||||
| 每回合资金 | 第 r 回合 `min(3 + r, 12)`:4、5、6、7 … 从第 9 回合起 12 |
|
||||
| 剩余资金 | **休整期结束时清零**(策略「坎诺特」例外) |
|
||||
| 剩余资金 | **休整期结束时清零**(策略「坎诺特」例外);还有资金时点「准备就绪」(或按 Space)会先弹窗确认一次 |
|
||||
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
|
||||
| 刷新 | 1 资金 |
|
||||
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
|
||||
|
||||
@@ -7174,7 +7174,7 @@
|
||||
},
|
||||
"layerGain": "寒檀 进入/结束休整期 +4 each; 伊内丝 +5 own active bonds; 赫默 +2; 圣约送葬人 +3 per 7 bullets (<=21/battle); 风笛 kills +2; 机变 \"风笛的盟誓\" +8.",
|
||||
"assumed": [
|
||||
"price never below 1"
|
||||
"no floor but 0 (owner's decision 2026-10-04; a floor of 1 was assumed until 0.1.3)"
|
||||
]
|
||||
},
|
||||
"members": [
|
||||
|
||||
@@ -798,7 +798,7 @@ Members (13 chess, 8 in current shop pool; by tier in shop: {'1': 2, '2': 1, '3'
|
||||
- **[first time L >= 150]** Replaces the above: every operator in the shop permanently -1 (overrides, does not stack; 3/27 update).
|
||||
- Formulas: `goldPayouts = 2 * floor(L/10)`
|
||||
- How layers are gained: 寒檀 进入/结束休整期 +4 each; 伊内丝 +5 own active bonds; 赫默 +2; 圣约送葬人 +3 per 7 bullets (<=21/battle); 风笛 kills +2; 机变 "风笛的盟誓" +8.
|
||||
- [ASSUMED] price never below 1
|
||||
- No floor but 0: the text names none, so a price of 1 (至简's 特质, 休露丝's first 谢拉格) becomes 0 (owner's decision 2026-10-04; this note assumed a floor of 1 until 0.1.3)
|
||||
- Garrisons that explicitly add layers to this bond: `garrison_50`, `garrison_55`, `garrison_63`, `garrison_64`, `garrison_141`, `garrison_142` (see section 4; plus the generic ones)
|
||||
|
||||
Members (9 chess, 8 in current shop pool; by tier in shop: {'2': 1, '3': 2, '4': 2, '5': 2, '6': 1}):
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { fxForm } from '../../../shared/protocol.js';
|
||||
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
|
||||
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js';
|
||||
import { useGameData, GIcon } from '../ui/gameComponents.js';
|
||||
import { useFieldView } from '../ui/fieldHost.js';
|
||||
import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js';
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
mergeTarget, modeOffBonds,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -628,11 +628,27 @@ function MatchScreen() {
|
||||
|
||||
// ---- actions ----------------------------------------------------------------------------------------------
|
||||
const buy = useCallback((i) => actions.buy(i), []);
|
||||
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
|
||||
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
|
||||
const askingReady = useRef(false);
|
||||
const toggleReady = useCallback(async (r) => {
|
||||
const L = live.current;
|
||||
const me = Array.isArray(L.pub?.players) ? L.pub.players.find((p) => p && p.playerId === L.myId) : null;
|
||||
const ask = r ? readyFundsPrompt(L.priv, { keptBands: data.get('config')?.economy?.leftoverFundsKeptByBands, autoplay: !!me?.autoplay }) : null;
|
||||
if (ask) {
|
||||
if (askingReady.current) return;
|
||||
askingReady.current = true;
|
||||
const ok = await confirmDialog(ask);
|
||||
askingReady.current = false;
|
||||
const now = live.current;
|
||||
if (!ok || now.pub?.phase !== PHASE.PREP || now.priv?.ready) return;
|
||||
}
|
||||
setReadyBusy(true);
|
||||
await actions.ready(r);
|
||||
setReadyBusy(false);
|
||||
}, []);
|
||||
// the prep ended (timer) while the question was open: drop it — the funds are gone either way
|
||||
useEffect(() => { if (phase !== PHASE.PREP && askingReady.current) closeAllDialogs(); }, [phase]);
|
||||
|
||||
/** 出售 (operators, underframe +N) / 销毁 (items and Arts, confirmed: they cannot be sold). */
|
||||
const sellPiece = useCallback(async (piece) => {
|
||||
|
||||
@@ -76,7 +76,7 @@ export function inviteLink(code) {
|
||||
*/
|
||||
export { copyText };
|
||||
|
||||
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
|
||||
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot, onKick }) {
|
||||
const coop = room.mode !== 'solo';
|
||||
if (!seat) {
|
||||
const canAdd = coop && facts.isHost;
|
||||
@@ -126,6 +126,9 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot
|
||||
${seat.isBot && facts.isHost ? html`<${Tooltip} text="移除该 AI 队友">
|
||||
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label="移除 AI 队友" />
|
||||
<//>` : null}
|
||||
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text="将该博士移出同盟">
|
||||
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label="移出该博士" />
|
||||
<//>` : null}
|
||||
</footer>
|
||||
</article>`;
|
||||
}
|
||||
@@ -194,6 +197,13 @@ export function RoomScreen() {
|
||||
const start = () => run('start', () => net.request('room.start', {}));
|
||||
const addBot = () => run('add', () => net.request('room.addBot', {}));
|
||||
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
|
||||
// the host removes a human before the match (community report #17): asked first; the player may join again. The
|
||||
// confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it.
|
||||
const kick = async (seat, name, playerId) => {
|
||||
if (inFlight.current) return;
|
||||
const ok = await confirmDialog({ title: '移出同盟', text: `确定将「${name || '博士'}」移出同盟吗?对方可以凭同盟密钥重新加入。`, okText: '移出', danger: true });
|
||||
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
|
||||
};
|
||||
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
|
||||
const leave = async () => {
|
||||
if (inFlight.current) return;
|
||||
@@ -251,7 +261,7 @@ export function RoomScreen() {
|
||||
|
||||
<main class=${`seats${coop ? '' : ' seats--solo'}`}>
|
||||
${facts.seats.map((s, i) => html`<${SeatCard} key=${s ? `p${s.playerId}` : `e${i}`} seat=${s} index=${i} room=${room} facts=${facts}
|
||||
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} />`)}
|
||||
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
|
||||
${coop ? null : html`<aside class="solo-brief brackets">
|
||||
<${MicroLabel} tone="mint">BRIEFING<//>
|
||||
<h2>${DIFFICULTY_NAMES[room.difficulty] || ''}<span class="num t-dim"> ${info.code}</span></h2>
|
||||
|
||||
@@ -787,6 +787,29 @@ export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 准备 confirmation (community report #4 after 0.1.2): readying with funds left asks first — the prep's end wipes
|
||||
* them (PRTS 卫戍协议/帮助 「本回合的剩余资金将清零」; the server still does: PlayerState.endPrep). act2autochess constData
|
||||
* `noMoneyTipsBand` — data/config.json economy.leftoverFundsKeptByBands, ["band_cannot"] — names the strategies whose
|
||||
* funds carry over and that get no such tip (坎诺特 利滚利). No prompt for an un-ready, with 0 funds, when already ready
|
||||
* or out, or under AI 托管 (the server plays the seat). The dialog's wording is the remake's own [ASSUMED]: neither the
|
||||
* tables nor PRTS hold the official one.
|
||||
* @param {any} priv m.private
|
||||
* @param {{ ready?: boolean, keptBands?: string[]|null, autoplay?: boolean }} [opts] `ready`: the state asked for
|
||||
* @returns {{ title: string, text: string, okText: string, cancelText: string, micro: string } | null}
|
||||
*/
|
||||
export function readyFundsPrompt(priv, { ready = true, keptBands = null, autoplay = false } = {}) {
|
||||
if (!ready || autoplay || !isObj(priv) || priv.alive === false || priv.ready) return null;
|
||||
const funds = Math.trunc(Number(priv.funds) || 0);
|
||||
if (!(funds > 0)) return null;
|
||||
const kept = Array.isArray(keptBands) ? keptBands : ['band_cannot'];
|
||||
if (typeof priv.bandId === 'string' && kept.includes(priv.bandId)) return null;
|
||||
return {
|
||||
title: '剩余资金', micro: 'FUNDS LEFT', okText: '准备就绪', cancelText: '继续整备',
|
||||
text: `还有 ${funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
+34
-2
@@ -8,9 +8,16 @@
|
||||
// Humans and bots take the lowest free seat index; seat indexes never compact.
|
||||
// * ▸ Being in a LOBBY room and sending room.create / room.join implicitly leaves it. While your room is
|
||||
// in a match, create/join of another room fails with ROOM_STARTED (send g.leave or room.leave first).
|
||||
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.start. ▸ Changing the difficulty
|
||||
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.kick, room.start. ▸ Changing the difficulty
|
||||
// un-readies the other humans. ▸ room.start requires every other human to be connected and ready;
|
||||
// the host's start counts as the host's ready (the host may still toggle room.ready for display).
|
||||
// * room.kick {seat, playerId} (community report #17, owner approved): before the match only, the host removes another
|
||||
// human like an AI seat (an AI seat stays room.removeBot's; never the host itself). `playerId` names the player the
|
||||
// host confirmed: a seat that changed hands meanwhile (left, someone else joined) is refused with BAD_TARGET. The
|
||||
// seat is freed at once and the player gets `room.closed {reason:'kicked'}` — now, or on the next resume when
|
||||
// offline (with the result replay, as the grace timeout) —, so the reconnect token no longer leads back to the seat
|
||||
// (it stays the player's identity: net.js sessions belong to players, not seats). ▸ No ban: the player may join
|
||||
// again with the code.
|
||||
// * Host migration: when the host leaves (or is removed), the lowest-seat remaining human (connected
|
||||
// ones first) becomes host. A room without humans is disposed (bots never keep a room alive).
|
||||
// * Disconnect in LOBBY: the seat shows connected=false and is freed after `lobbyGraceMs` (60 s); a
|
||||
@@ -47,7 +54,7 @@
|
||||
// matches, so a late b.progress / b.result of the previous match is ignored by the next one (DESIGN §14).
|
||||
// onEnd(summary) → room back to LOBBY (departed seats freed, humans un-readied, disconnected humans
|
||||
// get the lobby grace), dispose() on the next macrotask. Players can start again.
|
||||
// * room.closed reasons: 'timeout' (removed after lobby grace), 'shutdown' (server stopping).
|
||||
// * room.closed reasons: 'timeout' (removed after lobby grace), 'kicked' (room.kick), 'shutdown' (server stopping).
|
||||
// * Operator loadout (DESIGN §16): room.loadout { entries } is checked strictly against the game data
|
||||
// (shared/protocol.js checkLoadout: known visible chess, a skill index legal for the normal AND the elite status, a
|
||||
// module of the elite or 'none'; any bad entry rejects the whole message, nothing is stored). ▸ It is stored on the
|
||||
@@ -257,6 +264,7 @@ export class Lobby {
|
||||
case 'room.setDifficulty': return this.setDifficulty(session, msg);
|
||||
case 'room.addBot': return this.addBot(session);
|
||||
case 'room.removeBot': return this.removeBot(session, msg);
|
||||
case 'room.kick': return this.kick(session, msg);
|
||||
case 'room.start': return this.start(session);
|
||||
case 'room.loadout': return this.loadout(session, msg);
|
||||
default:
|
||||
@@ -421,6 +429,30 @@ export class Lobby {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** Host removes another human before the match (header: room.kick). */
|
||||
kick(session, { seat, playerId }) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
|
||||
if (room.match) return fail(ERR.ROOM_STARTED);
|
||||
this.dropReplay(room, session.playerId);
|
||||
const target = room.seats[seat];
|
||||
if (!target || target.left) return fail(ERR.BAD_TARGET, 'seat holds no player');
|
||||
if (target.playerId !== playerId) return fail(ERR.BAD_TARGET, 'seat changed hands'); // the confirmed player left meanwhile
|
||||
if (target.isBot) return fail(ERR.BAD_TARGET, 'seat holds an AI (room.removeBot)');
|
||||
if (target.playerId === session.playerId) return fail(ERR.BAD_TARGET, 'cannot kick yourself');
|
||||
const kicked = this.registry.byId(target.playerId);
|
||||
const wasHere = !!kicked && kicked.roomCode === room.code;
|
||||
const replay = this.replayFor(room, target.playerId);
|
||||
this.removeMember(room, target.playerId);
|
||||
if (wasHere) {
|
||||
if (kicked.connected) sendSession(kicked, { t: 'room.closed', reason: 'kicked' });
|
||||
else { kicked.notice = 'kicked'; kicked.pendingResult = replay; }
|
||||
}
|
||||
this.log.info(`[lobby] ${room.code} ${target.name} removed by the host`);
|
||||
return OK;
|
||||
}
|
||||
|
||||
start(session) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
|
||||
@@ -1373,6 +1373,11 @@ export class Match {
|
||||
}
|
||||
for (const ps of alive) ps.startRound(r);
|
||||
for (const ps of alive) this.dispatch(ps, 'onRoundStart', { round: r });
|
||||
// an eliminated player's pending 信标 gift still goes to its teammate (effects flagged afterElimination; GitHub #86)
|
||||
for (const ps of this.order) {
|
||||
if (ps.alive) continue;
|
||||
try { this.dispatcher.dispatchEliminated(ps, 'onRoundStart', { round: r }); } catch (e) { this.reportError('dispatch onRoundStart (eliminated)', e); }
|
||||
}
|
||||
for (const ps of alive) ps.recompute();
|
||||
this.setDeadline(DELAYS.ROUND_START / 1000, () => this.afterRoundStart(), { silent: this.soloUntimed });
|
||||
this.markPublic();
|
||||
|
||||
@@ -1489,6 +1489,8 @@ export class PlayerState {
|
||||
this.dirty();
|
||||
}
|
||||
|
||||
// `effects` are kept: a 信标 gift still pending is delivered to the teammate at the next round start (builtin_gift is
|
||||
// flagged afterElimination — GitHub #86); nothing else of an eliminated player is dispatched.
|
||||
eliminate(round) {
|
||||
this.alive = false;
|
||||
this.ready = false;
|
||||
|
||||
+39
-17
@@ -15,7 +15,8 @@
|
||||
// use_equip_reward_char_chess 拟态物质 3rd copy or a same-bond chess
|
||||
// use_equip_reward_special_goods_char_chess {refresh_cnt} 寻呼模块 offer N same-bond chess (≤ shop level)
|
||||
// use_equip_recruit_new_char_and_give_char_to_player_most_bond {refresh_cnt} 信标 destroy target, offer N same-tier
|
||||
// chess, gift the original next prep
|
||||
// chess, gift the original (an elite stays
|
||||
// elite) next prep, sender eliminated or not
|
||||
// sell_char_count_gain_equip_owner_bond {count} 商业包装方案 every `count` sells → same-bond chess
|
||||
// char_chess_transformation_equip 突变细胞 after battle: holder destroyed, its equipment
|
||||
// (the cell included — not consumed) back to
|
||||
@@ -43,6 +44,19 @@ function rollSameBond(ctx, bonds, maxTier, exclude = null) {
|
||||
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))) && !(exclude && exclude.includes(id)); } });
|
||||
}
|
||||
|
||||
/** 信标: the living teammate (not the caller) with the most members of `bonds` ("相应盟约人数最多"), ties at random; null without one. */
|
||||
function mostBondMate(ctx, bonds) {
|
||||
const mates = ctx.teammates();
|
||||
if (!mates.length) return null;
|
||||
let best = -1;
|
||||
let pick = [];
|
||||
for (const t of mates) {
|
||||
const score = bonds.reduce((s, b) => s + t.bondCount(b), 0);
|
||||
if (score > best) { best = score; pick = [t]; } else if (score === best) pick.push(t);
|
||||
}
|
||||
return ctx.rng.pick(pick) || null;
|
||||
}
|
||||
|
||||
const ITEM_HANDLERS = {
|
||||
equip_destory_gain_random_coin: {
|
||||
onEquip(ctx, ev) {
|
||||
@@ -135,6 +149,12 @@ const ITEM_HANDLERS = {
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item' });
|
||||
},
|
||||
},
|
||||
// 信标 (act2autochess eff_acarm109 / eff_acgarm109 "装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的
|
||||
// 干员,免费获取其中一名 / 若在同盟模拟中且存在其他队友,下个休整期向相应盟约人数最多的队友发送1个原干员 / 相应盟约人数相同则
|
||||
// 随机发送"; 芬 band_fang "原干员在下回合传递给对应盟约人数最多的队友"). The gift is the ORIGINAL operator: an elite carrier
|
||||
// is sent as that elite (community report #6 after 0.1.2: it arrived as the normal card — the gift carried baseIdOf).
|
||||
// Destroying the carrier returns its pool copies (an elite's 3) at once; the gift takes them again when it is granted
|
||||
// (effect:builtin_gift: grantChess → acquireChess, an elite takes up to goldenCopies, as every effect grant).
|
||||
use_equip_recruit_new_char_and_give_char_to_player_most_bond: {
|
||||
onEquip(ctx, ev) {
|
||||
const target = ctx.piece(ev.target.uid);
|
||||
@@ -142,7 +162,7 @@ const ITEM_HANDLERS = {
|
||||
const tier = ctx.gd.tierOf(target.id);
|
||||
const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 2));
|
||||
const bonds = ctx.pieceBonds(target.uid);
|
||||
const original = ctx.gd.baseIdOf(target.id);
|
||||
const original = target.id; // the elite id for an elite (report #6)
|
||||
ctx.destroyPiece(target.uid);
|
||||
const ids = [];
|
||||
// different operators of the target's tier, topped up from the tier below like the promotion reward (item 19)
|
||||
@@ -153,16 +173,8 @@ const ITEM_HANDLERS = {
|
||||
}
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item', tier });
|
||||
// co-op: next prep, send the original chess to the teammate with the most members of its bonds
|
||||
const mates = ctx.teammates();
|
||||
if (!mates.length) return;
|
||||
let best = -1;
|
||||
let pick = [];
|
||||
for (const t of mates) {
|
||||
const score = bonds.reduce((s, b) => s + t.bondCount(b), 0);
|
||||
if (score > best) { best = score; pick = [t]; } else if (score === best) pick.push(t);
|
||||
}
|
||||
const to = ctx.rng.pick(pick);
|
||||
if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original } });
|
||||
const to = mostBondMate(ctx, bonds);
|
||||
if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original, bonds } });
|
||||
},
|
||||
},
|
||||
sell_char_count_gain_equip_owner_bond: {
|
||||
@@ -230,15 +242,25 @@ const EFFECT_HANDLERS = {
|
||||
if (n > 0) ctx.addFunds(n, 'doll');
|
||||
},
|
||||
},
|
||||
// 信标's gift: at the next round start (下个休整期) the original operator goes to the teammate picked at equip time. It
|
||||
// hangs on the SENDER and runs even when the sender was eliminated in between (`afterElimination`: Match.startRound →
|
||||
// EffectDispatcher.dispatchEliminated) — an eliminated player gets no onRoundStart, so the gift was lost (GitHub #86).
|
||||
// A receiver eliminated meanwhile is replaced by the living teammate with the most members of the operator's bonds
|
||||
// (ties at random) [ASSUMED]; with no teammate left alive the gift is dropped. The effect is removed only after the
|
||||
// operator was granted: a grant that fails (no copy of it left in the shared pool, the receiver's 整备区 and temp
|
||||
// full) keeps it for the next round start — the official text names no refund [ASSUMED].
|
||||
builtin_gift: {
|
||||
afterElimination: true,
|
||||
onRoundStart(ctx) {
|
||||
const ref = ctx.source.ref;
|
||||
ctx.removeEffect(ref.id);
|
||||
const p = ref.params || {};
|
||||
const to = ctx.player(p.toPlayerId);
|
||||
if (!to || !p.chessId) return;
|
||||
const got = to.grantChess(p.chessId);
|
||||
if (got) to.giftTicker(ctx.name, p.chessId);
|
||||
const rec = p.chessId ? ctx.gd.chess(p.chessId) : null;
|
||||
if (!rec) { ctx.removeEffect(ref.id); return; }
|
||||
const to = ctx.player(p.toPlayerId) || mostBondMate(ctx, Array.isArray(p.bonds) ? p.bonds : rec.bonds || []);
|
||||
if (!to) { ctx.removeEffect(ref.id); return; }
|
||||
if (!to.grantChess(p.chessId)) return;
|
||||
ctx.removeEffect(ref.id);
|
||||
to.giftTicker(ctx.name, p.chessId);
|
||||
},
|
||||
},
|
||||
// 整备: the next purchased item becomes advanced (golden)
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
// then hand) → equipped items → effects (insertion order); onPrice runs the priced chess's own 特质 first (购买价格为N
|
||||
// sets the price the discounts and caps of bonds / strategies then act on — user playtest #5). Every call is
|
||||
// try/catch-guarded; nested dispatch depth is capped (MAX_DEPTH) so content can never loop the server.
|
||||
// An eliminated player gets no dispatch, except the persistent effects whose handler sets `afterElimination: true`
|
||||
// (onRoundStart, EffectDispatcher.dispatchEliminated: 信标's gift — GitHub #86).
|
||||
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { boardOrder, parseKey, tileKey } from './board.js';
|
||||
@@ -239,6 +241,26 @@ export class EffectDispatcher {
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* `hook` for an ELIMINATED player (Match.startRound: onRoundStart): only its persistent effects whose handler sets
|
||||
* `afterElimination: true` run — 信标's gift still reaches the teammate when its sender is out (builtinMeta.js
|
||||
* builtin_gift, GitHub #86). Nothing else of an eliminated player is dispatched.
|
||||
*/
|
||||
dispatchEliminated(ps, hook, ev = {}) {
|
||||
if (!ps || ps.alive || this.depth >= MAX_DEPTH) return ev;
|
||||
this.depth++;
|
||||
try {
|
||||
for (const ref of ps.effects.slice()) {
|
||||
if (!ref || typeof ref.key !== 'string') continue;
|
||||
const h = this.registry.get(ref.key);
|
||||
if (h && h.afterElimination === true) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
|
||||
}
|
||||
} finally {
|
||||
this.depth--;
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
/** Hooks a garrison handler runs on: its eventType's hook, or what `handler.garrisonHooks(garrison)` returns. */
|
||||
_garrisonHooks(h, g) {
|
||||
if (typeof h.garrisonHooks === 'function') {
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
// Funds earned after the prep phase ended (助力 / 寒檀 / 号角 … prep-end layers — the match wipes leftover funds right
|
||||
// after onPrepEnd) are paid as pending funds, credited at the next round start, so "获得…资金" is never silently lost
|
||||
// [ASSUMED]; `global:bondaddon_prepend` (first in every dispatch) marks the round whose prep has ended.
|
||||
// 远见 discounts never push a price below 1 (research 02 §3.15 [ASSUMED]; a price already below 1 is left alone).
|
||||
// 远见's −1 (「购买价格永久-1资金」) has no floor but 0: a price of 1 (至简 / 红豆's 购买价格为1 特质, run first on onPrice;
|
||||
// 休露丝's first 谢拉格) becomes 0 — the text names none (owner's decision 2026-10-04; before 0.1.3 research 02 §3.15
|
||||
// assumed 1).
|
||||
// Latched state lives in player counters (prefix `bondaddon:`); onPrice only reads them (pure).
|
||||
|
||||
const C_VISI_PAID = 'bondaddon:visi:paid';
|
||||
@@ -114,8 +116,7 @@ export function registerMeta(registry) {
|
||||
const rec = ctx.chessRecord(ev.id);
|
||||
if (!rec || !Array.isArray(rec.bonds) || !rec.bonds.includes(p.bond || 'visiShip')) return;
|
||||
}
|
||||
const price = n(ev.price);
|
||||
if (price > 1) ctx.modifyPrice(-Math.min(disc, price - 1));
|
||||
ctx.modifyPrice(-disc); // never below 0 (ctx.modifyPrice); 1 → 0 like any price (owner's decision 2026-10-04)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
// * 瑰盐 "优先…更靠上的和更靠右的": highest row first (DESIGN §3.1: row 0 is the bottom), then highest column.
|
||||
// * 购买价格为N (SERVER_CHESS_PRICE): bb.price is a discount off the tier price — 至简 (Ⅲ, 3) has 2 → 1, 红豆 (Ⅰ, 2)
|
||||
// has 1 → 1, exactly the N both official texts give (user playtest #5: 至简 costs 1). The dispatcher runs it before
|
||||
// every other onPrice modifier, so 远见's discount (never below 1) and strategy caps act on the lowered price.
|
||||
// every other onPrice modifier, so 远见's discount (to 0 at 150 layers since 0.1.3) and strategy caps act on the
|
||||
// lowered price.
|
||||
// * [ASSUMED] 余 SERVER_MOST_BOND: ties between most-member bonds are shuffled; the chess is a copy-weighted pool roll
|
||||
// of any tier (the text gives no tier cap); a bond without an available chess falls through to the next tied one.
|
||||
// * [ASSUMED] 松果: the "免费特殊招募" is a free pick-one offer of `rewardOffer.count` (3) chess of the pool's tier.
|
||||
@@ -215,7 +216,7 @@ H.SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT = {
|
||||
|
||||
// "购买价格为N": bb.price is the discount off the chess's tier price — 至简 (Ⅲ, 3 资金) carries 2 and 红豆 (Ⅰ, 2 资金)
|
||||
// carries 1, and both texts say 购买价格为1 (read as the new price, 至简 cost 2; user playtest #5). The dispatcher runs
|
||||
// this first on onPrice (effectsMeta.js), so bonds (远见 −1, never below 1) and strategies see the lowered price.
|
||||
// this first on onPrice (effectsMeta.js), so bonds (远见 −1: 1 → 0) and strategies see the lowered price.
|
||||
H.SERVER_CHESS_PRICE = {
|
||||
onPrice(ctx, ev) {
|
||||
const p = ctx.source.bb.price;
|
||||
|
||||
@@ -245,6 +245,9 @@ export const C2S = {
|
||||
'room.setDifficulty': { difficulty: (v) => DIFFICULTIES.includes(v) },
|
||||
'room.addBot': {},
|
||||
'room.removeBot': { seat: (v) => isInt(v, 0, MAX_SEATS - 1) },
|
||||
// the host removes another human before the match (server/lobby.js kick; community report #17); playerId = the one the
|
||||
// host confirmed — a seat that changed hands meanwhile is refused
|
||||
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
|
||||
'room.start': {},
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
||||
'room.loadout': { entries: isLoadoutEntries },
|
||||
|
||||
@@ -630,17 +630,19 @@ test('远见 meta: a milestone crossed after the prep phase ended (助力 +2 at
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('远见 meta: the discounts never push a price below 1', () => {
|
||||
test('远见 meta: 「购买价格永久-1资金」 has no floor but 0 — a price of 1 becomes 0 (owner\'s decision 2026-10-04)', () => {
|
||||
const { m, ps } = metaMatch(addonRegistry(), 94);
|
||||
ps.counters['bondaddon:visi:disc'] = 2; // 150 layers reached: every chess −1
|
||||
const at = (basePrice) => ps.priceOf({ kind: 'chess', id: 'chess_char_1_09_a', basePrice });
|
||||
assert.equal(at(3), 2);
|
||||
assert.equal(at(2), 1);
|
||||
assert.equal(at(1), 1, 'a price of 1 (至简 / 休露丝) stays 1');
|
||||
assert.equal(at(1), 0, 'a price of 1 (至简 / 休露丝) becomes 0');
|
||||
assert.equal(at(0), 0, 'never negative');
|
||||
ps.counters['bondaddon:visi:disc'] = 1; // 80: 远见 chess only
|
||||
assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 2 }), 1);
|
||||
assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 1 }), 1);
|
||||
assert.equal(ps.priceOf({ kind: 'chess', id: 'chess_char_2_02_a', basePrice: 1 }), 0, '80: a 远见 operator at 1 → 0 too');
|
||||
assert.equal(at(2), 2, 'non-远见 chess unchanged at 80');
|
||||
assert.equal(at(1), 1);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ test('SERVER_CHESS_PRICE: 购买价格为1 — bb.price is the discount off the
|
||||
assert.equal(GR('garrison_13_b').bb.price, 2);
|
||||
});
|
||||
|
||||
test('regression: 购买价格为1 runs before the other price modifiers (远见 −1 never below 1 leaves 至简 at 1)', () => {
|
||||
test('regression: 购买价格为1 runs before the other price modifiers — 远见 150 (−1, no floor since 0.1.3) takes 至简 to 0', () => {
|
||||
const s = setup();
|
||||
const seen = [];
|
||||
s.m.dispatcher.registry.global('gar_test_seen', { onPrice(ctx, ev) { seen.push(ev.price); } });
|
||||
@@ -522,9 +522,13 @@ test('regression: 购买价格为1 runs before the other price modifiers (远见
|
||||
s.ps.shop.slots[1] = { kind: 'chess', id: 'chess_char_1_02_a', basePrice: 2, frozen: false, sold: false };
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1);
|
||||
assert.deepEqual(seen, [1], 'a global modifier already sees 至简 at 1');
|
||||
s.ps.counters['bondaddon:visi:disc'] = 2; // 远见 150 layers: every chess −1, never below 1
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 3 − 2 = 1; 远见 leaves it at 1');
|
||||
s.ps.counters['bondaddon:visi:disc'] = 2; // 远见 150 layers: every chess −1, down to 0
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 0, '至简 3 − 2 = 1, then 远见 −1 → 0 (owner\'s decision 2026-10-04)');
|
||||
assert.equal(s.ps.priceOf(s.ps.shop.slots[1]), 1, 'other chess: 2 − 1');
|
||||
assert.equal(s.ps.privateView().shop.slots[0].price, 0, 'the shop card gets 0 (drawn FREE)');
|
||||
s.ps.funds = 0;
|
||||
assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'bought with no funds');
|
||||
assert.equal(s.ps.funds, 0);
|
||||
} finally {
|
||||
s.m.dispatcher.registry.unregister('global:gar_test_seen');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// test/lobby-kick.test.js — room.kick (community report #17, owner approved): before the match the host removes another
|
||||
// human like an AI seat (server/lobby.js kick). The player gets room.closed {kicked} (now, or on the next resume when
|
||||
// offline), the reconnect token no longer leads back to the seat, and the player may join again (no ban) [ASSUMED].
|
||||
// The message names the confirmed player too: a seat that changed hands while the host's dialog was open is refused.
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startServer } from '../server/index.js';
|
||||
import { StubMatch } from '../server/match/StubMatch.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
import { ERR } from '../shared/constants.js';
|
||||
import { validateC2S } from '../shared/protocol.js';
|
||||
|
||||
function clientPool(getUrl) {
|
||||
const open = new Set();
|
||||
return {
|
||||
async connect() { const c = await TestClient.connect(getUrl()); open.add(c); return c; },
|
||||
async player(name, token) {
|
||||
const c = await this.connect();
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
},
|
||||
async closeAll() {
|
||||
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
|
||||
open.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
const quietLog = () => {
|
||||
const errors = [];
|
||||
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
|
||||
};
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
|
||||
const seatOf = (state, id) => state.seats.find((s) => s && s.playerId === id) || null;
|
||||
async function createRoom(c) {
|
||||
await ok(c, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
return c.waitFor('room.state', (s) => s.hostId === c.id);
|
||||
}
|
||||
async function joinRoom(c, code) {
|
||||
await ok(c, { t: 'room.join', code });
|
||||
return c.waitFor('room.state', (s) => s.code === code && !!seatOf(s, c.id));
|
||||
}
|
||||
|
||||
describe('room.kick (lobby)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: StubMatch, lobbyGraceMs: 60_000 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { await pool.closeAll(); });
|
||||
after(async () => {
|
||||
await srv?.close();
|
||||
assert.deepEqual(cap.errors, [], 'no server errors logged');
|
||||
});
|
||||
|
||||
test('protocol: room.kick {seat, playerId} — a seat index and the confirmed player', () => {
|
||||
assert.equal(validateC2S({ t: 'room.kick', seat: 1, playerId: 'p_0123456789' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'room.kick', seat: 1 }), null, 'the player is required');
|
||||
assert.notEqual(validateC2S({ t: 'room.kick', seat: 9, playerId: 'p_0123456789' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'room.kick', seat: 1, playerId: '' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'room.kick' }), null);
|
||||
});
|
||||
|
||||
test('the host kicks a connected human: room.closed {kicked}, the seat is freed, the player may join again', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await joinRoom(guest, st.code);
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
|
||||
guest.clearInbox();
|
||||
await ok(host, { t: 'room.kick', seat: 1, playerId: guest.id });
|
||||
const closed = await guest.waitFor('room.closed');
|
||||
assert.equal(closed.reason, 'kicked');
|
||||
const after = await host.waitFor('room.state', (s) => s.seats[1] === null);
|
||||
assert.equal(after.hostId, host.id);
|
||||
await guest.expectNone('room.state');
|
||||
await err(guest, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM);
|
||||
// no ban: the same player joins again (lowest free seat)
|
||||
const back = await joinRoom(guest, st.code);
|
||||
assert.equal(seatOf(back, guest.id).seat, 1);
|
||||
assert.equal(seatOf(back, guest.id).ready, false);
|
||||
});
|
||||
|
||||
test('an offline human is removed at once; the old token resumes outside the room with room.closed {kicked}', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await joinRoom(guest, st.code);
|
||||
await guest.terminate();
|
||||
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false);
|
||||
await ok(host, { t: 'room.kick', seat: 1, playerId: guest.id });
|
||||
await host.waitFor('room.state', (s) => s.seats[1] === null);
|
||||
// the host can start alone right away (the offline seat no longer blocks it with NOT_READY)
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('room.state', (s) => s.inMatch === true);
|
||||
const again = await pool.connect();
|
||||
const w = await again.hello('Guest', guest.token);
|
||||
assert.equal(w.resumed, true, 'the identity survives');
|
||||
assert.equal(w.playerId, guest.id);
|
||||
const closed = await again.waitFor('room.closed');
|
||||
assert.equal(closed.reason, 'kicked');
|
||||
await again.expectNone('room.state');
|
||||
await err(again, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM);
|
||||
});
|
||||
|
||||
test('refusals: not the host, yourself, an AI seat, an empty seat, during a match', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await joinRoom(guest, st.code);
|
||||
await err(guest, { t: 'room.kick', seat: 0, playerId: host.id }, ERR.NOT_HOST);
|
||||
const self = await err(host, { t: 'room.kick', seat: 0, playerId: host.id }, ERR.BAD_TARGET);
|
||||
assert.match(self.detail || self.msg, /yourself/);
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
const withBot = await host.waitFor('room.state', (s) => s.seats[2]?.isBot);
|
||||
await err(host, { t: 'room.kick', seat: 2, playerId: withBot.seats[2].playerId }, ERR.BAD_TARGET);
|
||||
await err(host, { t: 'room.kick', seat: 3, playerId: guest.id }, ERR.BAD_TARGET);
|
||||
await err(host, { t: 'room.kick', seat: 1 }, ERR.BAD_MSG);
|
||||
const stranger = await pool.player('Stranger');
|
||||
await err(stranger, { t: 'room.kick', seat: 1, playerId: guest.id }, ERR.NOT_IN_ROOM);
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('room.state', (s) => s.inMatch === true);
|
||||
await err(host, { t: 'room.kick', seat: 1, playerId: guest.id }, ERR.ROOM_STARTED);
|
||||
});
|
||||
|
||||
test('a seat that changed hands while the host confirmed is refused: the newcomer stays (review of #17)', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const alice = await pool.player('Alice');
|
||||
await joinRoom(alice, st.code);
|
||||
await host.waitFor('room.state', (s) => seatOf(s, alice.id)?.seat === 1);
|
||||
// the host's dialog names Alice … she leaves and Bob takes seat 1 before the host confirms
|
||||
await ok(alice, { t: 'room.leave' });
|
||||
await host.waitFor('room.state', (s) => s.seats[1] === null);
|
||||
const bob = await pool.player('Bob');
|
||||
const joined = await joinRoom(bob, st.code);
|
||||
assert.equal(seatOf(joined, bob.id).seat, 1);
|
||||
bob.clearInbox();
|
||||
const stale = await err(host, { t: 'room.kick', seat: 1, playerId: alice.id }, ERR.BAD_TARGET);
|
||||
assert.match(stale.detail || stale.msg, /changed hands/);
|
||||
await bob.expectNone('room.closed');
|
||||
await ok(bob, { t: 'room.ready', ready: true });
|
||||
const still = await host.waitFor('room.state', (s) => seatOf(s, bob.id)?.ready);
|
||||
assert.equal(seatOf(still, bob.id).seat, 1, 'Bob keeps the seat');
|
||||
// naming Bob works
|
||||
await ok(host, { t: 'room.kick', seat: 1, playerId: bob.id });
|
||||
assert.equal((await bob.waitFor('room.closed')).reason, 'kicked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// 芬's 信标 gift (server/match/builtinMeta.js use_equip_recruit_new_char_and_give_char_to_player_most_bond →
|
||||
// effect:builtin_gift): act2autochess eff_acarm109 "下个休整期向相应盟约人数最多的队友发送1个原干员"; band_fang
|
||||
// "原干员在下回合传递给对应盟约人数最多的队友".
|
||||
// * community report #6 after 0.1.2: an ELITE carrier arrived at the teammate as the normal card — the gift is the
|
||||
// original operator, an elite with its 3 pool copies;
|
||||
// * GitHub #86: a sender eliminated before the next round start got no onRoundStart, so the gift was lost — it is
|
||||
// delivered anyway (afterElimination); a failed grant keeps the gift for the next round start; a receiver
|
||||
// eliminated meanwhile is replaced by the living teammate with the most members of the bonds [ASSUMED].
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, give, giveItem, DATA, legalTileFor } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
|
||||
const QUIET = { warn() {}, error() {}, info() {} };
|
||||
const REG = createRegistry({ log: QUIET });
|
||||
const OK = { ok: true };
|
||||
const BEACON = 'chess_item_5_04_e_a';
|
||||
|
||||
/** Co-op match of `humans` humans at PREP R1, every board / hand emptied (pool copies returned), no strategies. */
|
||||
function setup({ humans = 2, seed = 31 } = {}) {
|
||||
const h = makeMatch({ mode: 'coop', humans, seed, registry: REG, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
for (const ps of m.players.values()) {
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0;
|
||||
ps.funds = 50; ps.layers = {}; ps.pendingFunds = 0; ps.shop.freeRefreshes = 0;
|
||||
ps.bandId = null;
|
||||
ps.recompute();
|
||||
}
|
||||
const equip = (item, target, pid = 'p_0') => m.handle(pid, { t: 'g.equip', itemUid: item.uid, targetUid: target.uid });
|
||||
return { h, m, equip };
|
||||
}
|
||||
/** Visible normal chess (with an elite) whose 特质 are all IN_BATTLE, sorted. */
|
||||
const plain = (pred = () => true) => Object.values(DATA.chess)
|
||||
.filter((c) => c.visible && !c.isGolden && c.goldenId && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c))
|
||||
.map((c) => c.chessId).sort();
|
||||
const owned = (ps) => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'chess');
|
||||
const ofBase = (m, ps, base) => owned(ps).filter((p) => m.gd.baseIdOf(p.id) === base);
|
||||
const gifts = (ps) => ps.effects.filter((e) => e.key === 'effect:builtin_gift');
|
||||
/** `ps` drops to 1 LP and every field leaks one enemy (nobody perfect: no 联防), so `ps` is eliminated at the settle. */
|
||||
const knockOut = (ps) => { ps.lp = 1; FakeBattle.script = (b) => ({ leaks: Object.fromEntries(b.players.map((id) => [id, 1])) }); };
|
||||
const deploy = (m, ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
|
||||
|
||||
test('信标 on an elite: the teammate receives that elite with its 3 pool copies, not the normal card (community report #6)', () => {
|
||||
const { h, m, equip } = setup({ seed: 31 });
|
||||
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
|
||||
const cid = plain((c) => c.tier === 3 && c.bonds.length)[0];
|
||||
const gid = DATA.chess[cid].goldenId;
|
||||
const t = give(m, p0, gid, 'hand');
|
||||
assert.equal(t.poolCopies, m.gd.goldenCopies);
|
||||
const left = m.pool.left(cid);
|
||||
assert.deepEqual(equip(giveItem(m, p0, BEACON), t), OK);
|
||||
assert.equal(p0.find(t.uid), null, 'carrier destroyed');
|
||||
assert.equal(m.pool.left(cid), left + 3, 'its 3 copies are back in the pool');
|
||||
for (const s of p0.offers[0].slots) assert.equal(DATA.chess[s.id].tier, 3, 'the pick of two stays at the carrier\'s tier');
|
||||
assert.equal(gifts(p0)[0].params.chessId, gid, 'the gift carries the elite id');
|
||||
h.toPrep(2);
|
||||
const got = ofBase(m, p1, cid);
|
||||
assert.equal(got.length, 1, 'gift arrived');
|
||||
assert.equal(got[0].id, gid, 'as the elite');
|
||||
assert.equal(got[0].poolCopies, 3, 'holding the 3 copies');
|
||||
assert.equal(m.pool.left(cid), left, 'taken from the pool again');
|
||||
assert.equal(gifts(p0).length, 0, 'removed once granted');
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('信标 on a normal operator still sends the normal card', () => {
|
||||
const { h, m, equip } = setup({ seed: 35 });
|
||||
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
|
||||
const cid = plain((c) => c.tier === 2 && c.bonds.length)[0];
|
||||
assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, cid, 'hand')), OK);
|
||||
h.toPrep(2);
|
||||
assert.deepEqual(ofBase(m, p1, cid).map((p) => [p.id, p.poolCopies]), [[cid, 1]]);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('信标: the gift still arrives when its sender is eliminated before the next round start (GitHub #86)', () => {
|
||||
const { h, m, equip } = setup({ seed: 33 });
|
||||
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
|
||||
const cid = plain((c) => c.tier === 3 && c.bonds.length)[1];
|
||||
const gid = DATA.chess[cid].goldenId;
|
||||
assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, gid, 'hand')), OK);
|
||||
knockOut(p0);
|
||||
h.toPrep(2);
|
||||
assert.equal(p0.alive, false, 'the sender was eliminated at the R1 settle');
|
||||
assert.equal(p0.eliminatedRound, 1);
|
||||
assert.deepEqual(ofBase(m, p1, cid).map((p) => p.id), [gid], 'the teammate got the elite anyway');
|
||||
assert.equal(gifts(p0).length, 0, 'delivered, then removed');
|
||||
const line = h.allTo('p_1', 'm.ticker').find((t) => t.type === 'CHAR_GIFT');
|
||||
assert.ok(line && line.text.includes('P0') && line.text.includes(DATA.chess[gid].name), `CHAR_GIFT names the sender: ${line && line.text}`);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('信标: a grant that fails (no copy left in the pool) keeps the gift for the next round start', () => {
|
||||
const { h, m, equip } = setup({ seed: 37 });
|
||||
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
|
||||
const cid = plain((c) => c.tier === 2 && c.bonds.length)[1];
|
||||
assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, cid, 'hand')), OK);
|
||||
// every copy taken by "other players" before the round start
|
||||
const held = m.pool.take(cid, 99);
|
||||
assert.ok(held > 0);
|
||||
h.drive(() => m.phase === 'PREP' && m.round === 2);
|
||||
assert.equal(ofBase(m, p1, cid).length, 0, 'nothing granted');
|
||||
assert.equal(gifts(p0).length, 1, 'the gift is kept');
|
||||
m.pool.give(cid, held); // the copies come back (sold)
|
||||
h.toPrep(3);
|
||||
assert.deepEqual(ofBase(m, p1, cid).map((p) => p.id), [cid], 'delivered at the next round start');
|
||||
assert.equal(gifts(p0).length, 0);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('信标: a receiver eliminated before the round start is replaced by the living teammate with the most bond members [ASSUMED]', () => {
|
||||
const { h, m, equip } = setup({ humans: 3, seed: 39 });
|
||||
const p0 = h.ps('p_0'), p1 = h.ps('p_1'), p2 = h.ps('p_2');
|
||||
const cid = plain((c) => c.tier === 3 && c.bonds.length === 1)[0];
|
||||
const bond = DATA.chess[cid].bonds[0];
|
||||
const mates = plain((c) => c.bonds.includes(bond) && c.chessId !== cid);
|
||||
deploy(m, p1, mates[0]);
|
||||
deploy(m, p1, mates[1]); // p1 fields the most members → picked at equip time
|
||||
deploy(m, p2, mates[2]);
|
||||
assert.deepEqual(equip(giveItem(m, p0, BEACON), give(m, p0, cid, 'hand')), OK);
|
||||
assert.equal(gifts(p0)[0].params.toPlayerId, 'p_1');
|
||||
knockOut(p1);
|
||||
h.toPrep(2);
|
||||
assert.equal(p1.alive, false);
|
||||
assert.deepEqual(ofBase(m, p2, cid).map((p) => p.id), [cid], 'the next teammate got it');
|
||||
assert.equal(gifts(p0).length, 0);
|
||||
h.invariants();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// The 准备 confirmation with funds left (community report #4 after 0.1.2): public/js/ui/gameLogic.js readyFundsPrompt,
|
||||
// asked by public/js/screens/game.js toggleReady (the 准备 button and Space). The prep's end wipes unspent funds
|
||||
// (PRTS 卫戍协议/帮助 「本回合的剩余资金将清零」); act2autochess constData noMoneyTipsBand lists the strategies with no such
|
||||
// tip (坎诺特). The server rule is unchanged: readying is accepted, endPrep wipes the funds.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readyFundsPrompt } from '../../public/js/ui/gameLogic.js';
|
||||
import { makeMatch } from '../match/harness.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CONFIG = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8'));
|
||||
const KEPT = CONFIG.economy.leftoverFundsKeptByBands;
|
||||
const priv = (o = {}) => ({ alive: true, ready: false, funds: 5, bandId: 'band_bldsk', ...o });
|
||||
|
||||
test('readying with funds left asks first; the text names the funds and the wipe', () => {
|
||||
const ask = readyFundsPrompt(priv(), { keptBands: KEPT });
|
||||
assert.ok(ask);
|
||||
assert.match(ask.text, /5 资金/);
|
||||
assert.match(ask.text, /剩余资金将清零/);
|
||||
assert.equal(ask.okText, '准备就绪');
|
||||
assert.equal(ask.cancelText, '继续整备');
|
||||
assert.ok(readyFundsPrompt(priv({ funds: 1 }), { keptBands: KEPT }), '1 fund is enough to ask');
|
||||
});
|
||||
|
||||
test('no prompt: 0 funds, 坎诺特 (noMoneyTipsBand), un-ready, already ready, eliminated, AI 托管, no state', () => {
|
||||
assert.deepEqual(KEPT, ['band_cannot'], 'data/config.json carries the official noMoneyTipsBand');
|
||||
assert.equal(readyFundsPrompt(priv({ funds: 0 }), { keptBands: KEPT }), null);
|
||||
assert.equal(readyFundsPrompt(priv({ funds: -2 }), { keptBands: KEPT }), null);
|
||||
assert.equal(readyFundsPrompt(priv({ bandId: 'band_cannot', funds: 9 }), { keptBands: KEPT }), null, '坎诺特 keeps its funds');
|
||||
assert.equal(readyFundsPrompt(priv({ bandId: 'band_cannot', funds: 9 })), null, 'the official list is the fallback without config');
|
||||
assert.equal(readyFundsPrompt(priv(), { ready: false, keptBands: KEPT }), null, 'cancelling a ready never asks');
|
||||
assert.equal(readyFundsPrompt(priv({ ready: true }), { keptBands: KEPT }), null);
|
||||
assert.equal(readyFundsPrompt(priv({ alive: false }), { keptBands: KEPT }), null);
|
||||
assert.equal(readyFundsPrompt(priv(), { keptBands: KEPT, autoplay: true }), null, 'AI 托管');
|
||||
assert.equal(readyFundsPrompt(null, { keptBands: KEPT }), null);
|
||||
});
|
||||
|
||||
test('the server rule stays: readying with funds is accepted and the prep end wipes them (坎诺特 keeps them)', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 5, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
|
||||
p0.funds = 7; p0.bandId = 'band_bldsk';
|
||||
p1.funds = 6; p1.bandId = 'band_cannot';
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
|
||||
p0.endPrep(); p1.endPrep();
|
||||
assert.equal(p0.funds, 0);
|
||||
assert.equal(p1.funds, 6);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('game.js asks through readyFundsPrompt before g.ready (button and Space share toggleReady)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
|
||||
const body = src.slice(src.indexOf('const toggleReady = useCallback('), src.indexOf('const toggleReady = useCallback(') + 900);
|
||||
assert.match(body, /readyFundsPrompt\(L\.priv, \{ keptBands: data\.get\('config'\)\?\.economy\?\.leftoverFundsKeptByBands, autoplay: !!me\?\.autoplay \}\)/);
|
||||
assert.ok(body.indexOf('confirmDialog(ask)') < body.indexOf('actions.ready(r)'), 'confirmed before the request');
|
||||
assert.match(src, /onReady=\$\{toggleReady\}/);
|
||||
assert.match(src, /toggleReady\(!L\.priv\.ready\)/, 'Space');
|
||||
});
|
||||
Reference in new issue
Block a user