feedback7: merge fb7-ui-backlog — #229 device tooltips, #238 settings button in lobby / room, #138 准备 folds the shop, #120 in-match spectator roster (cap stays 2), PR #308 capsule counts (+ the shared-field progress fix), PR #333 empty new shop slot on 调度中心 upgrade
- Conflicts: the stats entry (merged fb7-stats) and the settings button both sit in the lobby / room top bars — both kept;
public/js/ui/settings.js (#321 diagnostics, voiceLang, SettingsButton); docs/PLAYING.md table rows resolved line by
line against the merge base (#368, #354, #333 edits kept); docs/SIM.md / design/network.md snapshot field lists
(resolved + ammo / wolves / neg / stand / standCut); design/client.md Screens line (#368's AI switch kept).
- Grok review: MERGE AFTER FIXES — the shared-field capsule finding fixed on the branch (1a880699).
- Golden: roster 23, bonds 2, fields 11, matches 18, diy 11 — #308 totals / snapshots and #333's shop RNG (union OK);
broad node --test 5473 (5472 pass, 0 fail, 1 skipped); lint 0 errors; typecheck; i18n strict clean.
This commit is contained in:
commit
dc609ea13f
71 files changed
+3016
-806
No files matched your search
+6
-4
@@ -508,7 +508,7 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
|||||||
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
|
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
|
||||||
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
|
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
|
||||||
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; an upgrade opens the new level's
|
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; an upgrade opens the new level's
|
||||||
extra slots at once with new cards (the cards shown stay); `MAX_LEVEL` at 6. One freeze
|
extra slots at once, empty (the cards shown stay; the empty slots fill on the next roll); `MAX_LEVEL` at 6. One freeze
|
||||||
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
|
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
|
||||||
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
|
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
|
||||||
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
|
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
|
||||||
@@ -826,9 +826,11 @@ round was over. The official 1 s `broadcastBeginDelay` is not modelled.
|
|||||||
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
|
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
|
||||||
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
|
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
|
||||||
* A manual refresh while frozen keeps the new slots frozen until the next round start.
|
* A manual refresh while frozen keeps the new slots frozen until the next round start.
|
||||||
* A level-up opens the new level's extra slots at once, each with a new card drawn at the new level, and keeps the
|
* A level-up opens the new level's extra slots at once — empty, with no card drawn into them (official footage, GitHub
|
||||||
cards shown (0.2.0, community report item 19; the official tutorial's 「升级后将出现更多的商品栏位」); the new card
|
#332: bilibili BV1AXwuzdEys 1:39, a 1→2 upgrade with the new slot visible and empty; the official texts only ever say
|
||||||
follows the freeze toggle like a manual refresh's cards.
|
「升级后将出现更多的商品栏位」/「增加刷新栏位」) — and keeps the cards shown. The empty slots fill on the next roll (a manual
|
||||||
|
refresh or the round start), like every unfrozen slot; the shop draws an empty slot as a bare frame, never 已招募. 0.2.0
|
||||||
|
drew a card into each new slot (community report item 19, one uncorroborated remark): reverted in 0.2.2.
|
||||||
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
|
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
|
||||||
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the
|
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the
|
||||||
cell included) before its new operator is gained into the 整备区 — the carrier's tile is left empty (official footage,
|
cell included) before its new operator is gained into the 整备区 — the carrier's tile is left empty (official footage,
|
||||||
|
|||||||
+6
-5
@@ -21,9 +21,10 @@
|
|||||||
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系(三种特训敌人类型,如折射、飞行:和官方一样,每种占全局 15 个回合位中的 3 个,第 14 回合领袖战和第 15 回合隐秘核心也各占一个位,所以险境 / 绝境 / 终极第 1–13 回合中有 7–9 波是这三种类型、每种最多 3 波,其余各波是「特异」类型;每波只有一种类型)、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
|
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系(三种特训敌人类型,如折射、飞行:和官方一样,每种占全局 15 个回合位中的 3 个,第 14 回合领袖战和第 15 回合隐秘核心也各占一个位,所以险境 / 绝境 / 终极第 1–13 回合中有 7–9 波是这三种类型、每种最多 3 波,其余各波是「特异」类型;每波只有一种类型)、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
|
||||||
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);创建者可以在同盟等待室打开「**AI 队友最后选择**」(默认关闭):选择策略和机变轮选时所有博士都排在 AI 队友前面(博士之间、AI 之间仍是随机顺序,AI 托管或掉线的博士也算博士),跳过时排到其他还没选的博士之后、AI 之前(没有其他博士时排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
|
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);创建者可以在同盟等待室打开「**AI 队友最后选择**」(默认关闭):选择策略和机变轮选时所有博士都排在 AI 队友前面(博士之间、AI 之间仍是随机顺序,AI 托管或掉线的博士也算博士),跳过时排到其他还没选的博士之后、AI 之前(没有其他博士时排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
|
||||||
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(随机顺序,开启「AI 队友最后选择」时博士先选;首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
|
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(随机顺序,开启「AI 队友最后选择」时博士先选;首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
|
||||||
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
|
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消;点「准备就绪」时商店自动收起、棋盘放大到全场,取消准备时商店再展开(资金旁的「收起」和商店标签「展开商店」随时可以手动切换)。 |
|
||||||
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
|
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
|
||||||
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
- **场地装置点一下也有说明**:**阻隔工事**(挡路:地面敌人绕着走,无路可走才撞碎它;作战中说明卡上有它的实时血量,被摧毁后点不出来)、**“双眼皮”炮台**(自动法术攻击、命中附加脆弱,按当前层数最高的盟约强化;还没被机械援助或机变卡启用的炮台不显示、点不出来)、**射击台**(远程位可部署的高台位)和**源石流发生装置**(吹风方向,以及对干员攻击力、敌人移动速度的加减)点一下同样弹出说明卡,数值都取自所在关卡的装置数据,装置名称按当前语言显示官方译名;被机变卡移除的箱子同样不显示。休整期和作战期都能点(GitHub #228,PR #229,感谢 @rickylxw)。
|
||||||
|
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。顶栏中间的胶囊是官方的「已解决 / 本回合敌人数」:打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物和领袖召唤的敌人不进分子也不进分母,但它们漏掉时仍然各自扣目标生命值、也会破坏完美作战(4 只磨砻全被打死、分裂出的 8 只全漏进蓝门:胶囊 4/4,扣 8 点),所以会召唤的领袖关胶囊也能填满。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||||
| 联防 | 见第 5 节。 |
|
| 联防 | 见第 5 节。 |
|
||||||
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
|
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
|
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
|
||||||
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 |
|
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 |
|
||||||
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新。策略「梓兰」的每次主动刷新会单独冻结送来的那张同名干员:只有这一张卡显示冰霜(上方的冻结按钮不亮),没有再刷新就留到下一回合,再手动刷新会连它一起重抽 |
|
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新。策略「梓兰」的每次主动刷新会单独冻结送来的那张同名干员:只有这一张卡显示冰霜(上方的冻结按钮不亮),没有再刷新就留到下一回合,再手动刷新会连它一起重抽 |
|
||||||
| 升级调度中心(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 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 |
|
| 升级调度中心(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 个)时,**新栏位立刻出现,但空着不补干员**(官方如此:升级只开位,新位在下一次刷新——手动刷新或回合开始的自动刷新——时才会出现干员;空栏位只画一个空框,不写「已招募」),已有的卡片不变 |
|
||||||
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
|
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
|
||||||
|
|
||||||
调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。
|
调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
|
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
|
||||||
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
||||||
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
||||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
|
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。
|
||||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||||
|
|
||||||
## 9. 结算称号
|
## 9. 结算称号
|
||||||
@@ -172,7 +173,7 @@
|
|||||||
|
|
||||||
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
||||||
|
|
||||||
**修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
||||||
|
|
||||||
## 12. 反馈问题
|
## 12. 反馈问题
|
||||||
|
|
||||||
|
|||||||
+28
-4
@@ -184,6 +184,9 @@ by a `battleStart` handler covers it like a later knock-out (机变 征召 −50
|
|||||||
|
|
||||||
`SpawnSpec = { time, enemyKey, routeIndex, count=1, interval=0, mods:{hpMul,atkMul,defMul,resMul,speedMul}, sourcePlayerId?,
|
`SpawnSpec = { time, enemyKey, routeIndex, count=1, interval=0, mods:{hpMul,atkMul,defMul,resMul,speedMul}, sourcePlayerId?,
|
||||||
bounty?:{coins, ownerPlayerId}, tag?:'boss'|'part'|'escort'|'bounty', ownerPlayerId?, pos?:[r,c], route?:RouteSpec, countInTotal? }`.
|
bounty?:{coins, ownerPlayerId}, tag?:'boss'|'part'|'escort'|'bounty', ownerPlayerId?, pos?:[r,c], route?:RouteSpec, countInTotal? }`.
|
||||||
|
A spec entry the battle queues (`_queueSpawn`) is `inTotal` when it counts (`countInTotal`, `tag`, data `notCountInTotal`):
|
||||||
|
that is the HUD capsule's own set (§1.3); everything content spawns at runtime (a split, a summon — a boss's included —,
|
||||||
|
a part) is not, even when it counts for LP (`counted`).
|
||||||
`RouteSpec` accepts data/waves.json routes (`{motion, start, end, checkpoints:[[r,c]…], steps:[{t:'move',p},{t:'wait',s},{t:'disappear'},{t:'appear',p}]}`)
|
`RouteSpec` accepts data/waves.json routes (`{motion, start, end, checkpoints:[[r,c]…], steps:[{t:'move',p},{t:'wait',s},{t:'disappear'},{t:'appear',p}]}`)
|
||||||
and research routes (`{m, s, e, cp:[['MOVE',r,c]…]}`). `spawnsFromTemplate(waveEntry, {mods})` (simdata.js) converts a
|
and research routes (`{m, s, e, cp:[['MOVE',r,c]…]}`). `spawnsFromTemplate(waveEntry, {mods})` (simdata.js) converts a
|
||||||
template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful`
|
template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful`
|
||||||
@@ -410,13 +413,32 @@ recorded for the player whose half contains the goal it reached, with `sourcePla
|
|||||||
### 1.3 BattleResult
|
### 1.3 BattleResult
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{ time, reason: 'cleared'|'timeout'|'forced', killed, total, errors, unspawned?: [{enemyKey,time,tag}], bossHpLeft?,
|
{ time, reason: 'cleared'|'timeout'|'forced', killed, total, resolved, errors, unspawned?: [{enemyKey,time,tag}], bossHpLeft?,
|
||||||
perPlayer: { [playerId]: { killed, total, perfect,
|
perPlayer: { [playerId]: { killed, total, resolved, killedInTotal, leakedInTotal, perfect,
|
||||||
leaked: [{ enemyKey, mods, lpr, sourcePlayerId, tag, counted, boss?, spawned }],
|
leaked: [{ enemyKey, mods, lpr, sourcePlayerId, tag, counted, boss?, spawned }],
|
||||||
layerGains: {bondId: n}, coins, damageDealt, bossDamage, healingDone, deaths,
|
layerGains: {bondId: n}, coins, damageDealt, bossDamage, healingDone, deaths,
|
||||||
unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }], // operators + board summon pieces; sp = skill.spTotal
|
unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }], // operators + board summon pieces; sp = skill.spTotal
|
||||||
unitStats: [{ id, uid, defId, name, kind, dmg, kills, heal, taken, attacks }] } } }
|
unitStats: [{ id, uid, defId, name, kind, dmg, kills, heal, taken, attacks }] } } }
|
||||||
```
|
```
|
||||||
|
- **Two sets of counters, and they never cross.** `counted` drives the official LP / 完美作战 / `killed` reading (master's,
|
||||||
|
untouched): every counted enemy — a runtime split child, a summon, a boss's summon included — is in it. `inTotal` (the
|
||||||
|
new flag, `spawns.js`) is the HUD capsule's own set (DESIGN §14 顶栏胶囊): only the enemies the field itself scheduled
|
||||||
|
(`_queueSpawn`, and only those that `counted` — `countInTotal: false`, `tag: 'boss'` / `'part'`, data `notCountInTotal`
|
||||||
|
stay out) are `inTotal`. Everything a runtime `spawnEnemy` makes defaults to `inTotal: false` (an explicit
|
||||||
|
`inTotal: true` is the content back door); a 变身 / 重生 (`setForm`, a revive) is the same enemy and keeps its flag.
|
||||||
|
- `total` counts the `inTotal` enemies (the capsule's denominator), `killedInTotal` / `leakedInTotal` (battle level and per
|
||||||
|
player) count those that were knocked down or leaked, and `resolved = min(total, killedInTotal + leakedInTotal)` is the
|
||||||
|
capsule's numerator (official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3). A leaked split child or summon
|
||||||
|
moves none of them, while `leakedCount` / `leaked[]` still charge its LP and break 完美作战. The per-player numbers are
|
||||||
|
attributed like their `counted` twins — a knock-out to the enemy's owner (as `total`), a leak to the player whose half it
|
||||||
|
reached (`_recordLeak`) — so in a two-half field (the 联防 lane, col 18 → col 2; the boss pair's crossing routes) an enemy
|
||||||
|
that spawns on one half and leaks on the other is in one player's `total` and in the other's `leakedInTotal`, and each
|
||||||
|
player's own `resolved` clamps it away (0 and 0 for an enemy the field resolved). Only the battle-level numbers are exact:
|
||||||
|
the match never sums the per-player `resolved` — `validateClientResult` keeps the result's own and `m.public` publishes
|
||||||
|
it (design/network.md 顶栏胶囊).
|
||||||
|
- `killed` therefore may **exceed** `total` (it counts runtime children, they do not): 4 磨砻 killed plus one of their 8
|
||||||
|
木制瑞印 reads `killed` 5 with `total` 4 and a capsule of 4/4. `spec.js compactResult` and the match's validation bound
|
||||||
|
`killed` by the spec's content-spawn allowance (`4·spawns + 100`) instead of by `total`.
|
||||||
- `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0 — a pool never holds less than
|
- `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0 — a pool never holds less than
|
||||||
`BOSS_POOL_MIN_HP` (1): the hit that would leave less takes the rest and the browser's `LocalBossPool` reads less as
|
`BOSS_POOL_MIN_HP` (1): the hit that would leave less takes the rest and the browser's `LocalBossPool` reads less as
|
||||||
0, so float dust of the pool sync can never keep a leader alive; user playtest #6). A boss/hidden battle **with a
|
0, so float dust of the pool sync can never keep a leader alive; user playtest #6). A boss/hidden battle **with a
|
||||||
@@ -833,7 +855,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
|||||||
| `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 |
|
| `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 |
|
||||||
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
|
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
|
||||||
| `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) |
|
| `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) |
|
||||||
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
|
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, inTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data. A runtime spawn is never `inTotal` (§1.3 — the HUD capsule's own set) unless content passes `inTotal: true` explicitly |
|
||||||
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
|
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
|
||||||
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
|
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
|
||||||
| `downOn(r, c, except?)`, `restTile(u)` | the down operator (`isDown`: knocked out or forced out) lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when down, else its home (§1) |
|
| `downOn(r, c, except?)`, `restTile(u)` | the down operator (`isDown`: knocked out or forced out) lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when down, else its home (§1) |
|
||||||
@@ -1296,7 +1318,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
|||||||
|
|
||||||
## 9. Wire format (snapshot.js, DESIGN §8.2)
|
## 9. Wire format (snapshot.js, DESIGN §8.2)
|
||||||
|
|
||||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }`.
|
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved, dps?, boss?, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }`.
|
||||||
|
`killed` counts every `counted` knock-out (§1.3) and `total` only the enemies this field scheduled (`inTotal`), so it may
|
||||||
|
exceed it; `resolved = min(total, killedInTotal + leakedInTotal)` is the HUD capsule's numerator (DESIGN §14 顶栏胶囊).
|
||||||
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
|
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
|
||||||
activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s.
|
activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s.
|
||||||
Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show
|
Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl
|
|||||||
|
|
||||||
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
|
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
|
||||||
|
|
||||||
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), the co-op 「AI 队友最后选择」 switch (host, off by default; read-only for the others), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
|
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms; a 设置 button left of 玩法说明 opens the title screen's settings dialog, GitHub #238), **Room** (4 seats, invite code + copy link, difficulty picker (host), the co-op 「AI 队友最后选择」 switch (host, off by default; read-only for the others), add/remove AI, ready, start; the same 设置 button), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule (the kill counter reads 已解决 / the round's own enemies: a leak counts, a split child or summon is in neither number, §14)/LP (live during the own battle, §17.5)/timer/ready, and beside the latency a 观战席 capsule (eye + count, a roster with the host's ✕) while the room has spectators (GitHub #120); bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards — a slot the upgrade just opened is a bare frame until the next refresh, GitHub #332 —, item card, funds, refresh, freeze, collapse; 准备就绪 folds it and cancelling unfolds it, GitHub #138); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
|
||||||
|
|
||||||
盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。
|
盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。
|
||||||
|
|
||||||
|
|||||||
+15
-7
@@ -12,18 +12,18 @@ S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId
|
|||||||
|
|
||||||
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); 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}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); 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.
|
||||||
|
|
||||||
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
|
**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
|
### 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}` · `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:
|
S→C:
|
||||||
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
|
- `m.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).
|
- `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).
|
||||||
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
|
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
|
||||||
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
|
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
|
||||||
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
|
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
|
||||||
`0/0`. Snapshot display capacity does not change runtime SP/cost.
|
`0/0`. Snapshot display capacity does not change runtime SP/cost.
|
||||||
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
|
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, resolved, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
|
||||||
Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's
|
Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's
|
||||||
attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls
|
attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls
|
||||||
between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and
|
between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and
|
||||||
@@ -77,20 +77,28 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
|
|||||||
**Authority per field.**
|
**Authority per field.**
|
||||||
- Normal field: the owning human's client is **authoritative** (if connected).
|
- Normal field: the owning human's client is **authoritative** (if connected).
|
||||||
- Unite field (联防) and boss/hidden pair fields: the lowest-seat **connected human** in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally **for display only**.
|
- Unite field (联防) and boss/hidden pair fields: the lowest-seat **connected human** in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally **for display only**.
|
||||||
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total(, left)]` progress timeline — `left`: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
|
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total, resolved(, left)]` progress timeline — `resolved`: the capsule's numerator on the field clock (the maintainer's review of PR #157: a bot field has no authority reporting `b.progress`), `left`: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
|
||||||
- Takeover: an authority that disconnects or leaves, or misses its deadline (`timeLimit / speed` real s + 15 s grace, normal/联防) loses the field: normal/联防 → the server re-simulates the spec from t = 0 (deterministic: the same result); boss → the partner's replica is promoted (`b.start authoritative: true`), else the server runs it, fast-forwarded to the field clock through a `CreditPool` that only credits damage beyond what the client already reported (the fast-forward is spread over the pacing intervals, ≤ 240 extra ticks each). A boss authority silent for 12 s is handed over the same way. The former authority gets `b.end { reason: 'takeover' }` and keeps displaying.
|
- Takeover: an authority that disconnects or leaves, or misses its deadline (`timeLimit / speed` real s + 15 s grace, normal/联防) loses the field: normal/联防 → the server re-simulates the spec from t = 0 (deterministic: the same result); boss → the partner's replica is promoted (`b.start authoritative: true`), else the server runs it, fast-forwarded to the field clock through a `CreditPool` that only credits damage beyond what the client already reported (the fast-forward is spread over the pacing intervals, ≤ 240 extra ticks each). A boss authority silent for 12 s is handed over the same way. The former authority gets `b.end { reason: 'takeover' }` and keeps displaying.
|
||||||
|
|
||||||
**Protocol (additions; `shared/protocol.js` is normative).**
|
**Protocol (additions; `shared/protocol.js` is normative).**
|
||||||
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` 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.
|
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` 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).
|
- 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, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). 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?, 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.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`).
|
- 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.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.
|
- 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.
|
||||||
- `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
|
- `m.public.fields[]` gains `progress: { killed, resolved, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
|
||||||
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
|
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
|
||||||
|
|
||||||
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
|
**顶栏胶囊 (the top bar's 击倒 / 漏掉 counter; PR #157, official reading).** The capsule shows `resolved / total`:
|
||||||
|
- `total` = the enemies **this field itself scheduled** (`Battle` `total`: the `spawns` the match built, `inTotal`) that count — `countInTotal: false`, `tag: 'boss'` / `'part'` and data `notCountInTotal` entries stay out, like the 领袖 of a boss field.
|
||||||
|
- `resolved` = `min(total, killedInTotal + leakedInTotal)` (`Battle.resolved`): that same set's enemies that are 已解决 — knocked down (`killedInTotal`) **or** leaked (`leakedInTotal`). Official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
|
||||||
|
- Everything spawned while the battle runs is in **neither** part: a split child (磨砻's 木制瑞印), a summon (枯朽萃聚使徒's 枯朽之种, a boss's 余音 / 妖怪 / 不祥幻影 / 王权号令 equipment), a 变身 copy, a part. They still count for LP, 完美作战 and `killed` exactly as master did (`counted`): a leaked split child costs its `lpr` and breaks 完美作战, it just moves no capsule number.
|
||||||
|
- `killed` (`Battle.killed`, per player too) keeps the official `counted` reading — a runtime split / summon knocked down counts — so it may **exceed** `total` (4 磨砻 killed plus one of their 8 children reads `killed` 5 with `total` 4 while the capsule reads 4/4). Nothing clamps it: the match's stats and the bot's scoring read those knock-outs.
|
||||||
|
- Carried by `b.snap`, `BattleResult` (battle level and per player, the per-player `resolved` clamped to the player's `total`), `b.progress` and `m.public.fields[].progress`. **A finished field publishes the field's own number, never the sum of the players':** in a two-half field (the 联防 lane, the boss pair's crossing routes) an enemy that spawns on one half and leaks on the other is billed to one player's `total` and to the other's `leakedInTotal`, so each player's own `min(total, …)` reads 0 for an enemy the field resolved (capsule 1/1, players 0 and 0). `validateClientResult` therefore keeps the result's field-level `resolved` (the client's `Battle.resolved`, clamped to the field's `total`) and `_fieldProgress` publishes it — for a server-run field too, whose `Battle.result()` carries the same number; the per-player numbers are only the fallback of a result that has none, and with none at all `progress.resolved` is `null`. A bot / server-run field has no authority reporting `b.progress`, so its capsule reads the battle's own counters through the `HeadlessJob` timeline sample (`[gt, killed, total, resolved(, left)]`), never the report-driven `progress.leaks` (which stays 0 there); `progress.resolved` starts at `null` and is only adopted from a real reported integer — a reported `0` is a value, "not reported" is not.
|
||||||
|
- The HUD reads it as `resolved ?? killed` (`ui/hud.js` two capsules, `ui/combatHud.js` teammates' rows, `ui/gameLogic/format.js snapHud`, `battle/observe.js teammateProgress`), so a server that does not send `resolved` displays master's numbers unchanged.
|
||||||
|
|
||||||
|
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ maxTotal` and `total ≤ maxTotal` (`4·spawns + 100`, the content-spawn allowance — `killed ≤ total` no longer holds, see the capsule above) and the field-level `resolved ≤ total`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
|
||||||
|
|
||||||
**Boss rounds.** The shared pool (`SharedBossPool`, size = `bloodPoint[difficulty]` × the players alive when the fight starts, solo × 1 — the owner's decision of 2026-10-06 adopting PR #209, which replaces the fixed pool of 「保持固定血量」, §25.13.4) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`; BOSS_HIT tickers from each player's damage to that round's pool, §21.10) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.
|
**Boss rounds.** The shared pool (`SharedBossPool`, size = `bloodPoint[difficulty]` × the players alive when the fight starts, solo × 1 — the owner's decision of 2026-10-06 adopting PR #209, which replaces the fixed pool of 「保持固定血量」, §25.13.4) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`; BOSS_HIT tickers from each player's damage to that round's pool, §21.10) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,9 @@
|
|||||||
.scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
.scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; }
|
.scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; }
|
||||||
.scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); }
|
.scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); }
|
||||||
|
/* a slot the 调度中心's upgrade opened and nothing filled yet (ui/shopBar.js EmptyCard, also .scard--sold): the bare frame — no hatching,
|
||||||
|
no SOLD OUT text */
|
||||||
|
.scard--sold.scard--empty { background: rgba(8, 11, 10, .4); border-color: var(--line); }
|
||||||
|
|
||||||
/* ---- funds card ---- */
|
/* ---- funds card ---- */
|
||||||
.funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); }
|
.funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); }
|
||||||
|
|||||||
@@ -30,6 +30,21 @@
|
|||||||
.gtop > * { pointer-events: auto; }
|
.gtop > * { pointer-events: auto; }
|
||||||
.gtop__left { display: flex; align-items: center; gap: .16rem; }
|
.gtop__left { display: flex; align-items: center; gap: .16rem; }
|
||||||
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
|
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
|
||||||
|
/* the latency pill and, beside it, the 观战席 capsule (ui/hud.js SpectatorPill: an eye + how many are watching; opens the roster) */
|
||||||
|
.gtop__net { display: flex; align-items: center; gap: .08rem; }
|
||||||
|
.specpill { --btn-bg: rgba(0, 0, 0, .5); --btn-bg-hover: rgba(0, 0, 0, .66); --btn-bd: var(--line); height: .32rem; min-width: 0; padding: 0 .12rem; gap: .05rem; border-radius: 99px; }
|
||||||
|
.specpill .btn__icon { width: .18rem; height: .18rem; color: var(--mint-500); }
|
||||||
|
.specpill__num { font-size: .18rem; font-weight: 700; color: var(--mint-400); }
|
||||||
|
.specpill.is-me { --btn-bd: var(--mint-600); }
|
||||||
|
/* the roster inside its modal */
|
||||||
|
.spec__roster { display: flex; flex-direction: column; gap: .06rem; margin: 0 0 .1rem; padding: 0; list-style: none; }
|
||||||
|
.spec__row { display: flex; align-items: center; gap: .1rem; min-height: .44rem; padding: .04rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid transparent; }
|
||||||
|
.spec__row.is-me { border-color: var(--mint-700); background: rgba(45, 212, 191, .08); }
|
||||||
|
.spec__row.is-offline .spec__name { color: var(--text-dim); }
|
||||||
|
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
|
||||||
|
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
|
||||||
|
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); }
|
||||||
|
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
|
||||||
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
|
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
|
||||||
.gtop__center {
|
.gtop__center {
|
||||||
--bk-c: var(--mint-700);
|
--bk-c: var(--mint-700);
|
||||||
@@ -501,6 +516,10 @@
|
|||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* ---- settings modal ------------------------------------------------------------------------------------------------ */
|
/* ---- settings modal ------------------------------------------------------------------------------------------------ */
|
||||||
|
/* the 设置 trigger of the lobby and the room (ui/settings.js SettingsButton, GitHub #238): the 玩法说明 button's twin; its gear sits
|
||||||
|
inside the label, so the label lays glyph and text out like the button itself */
|
||||||
|
.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; }
|
||||||
|
.settings-btn .btn__icon { color: var(--mint-500); }
|
||||||
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
|
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
|
||||||
.set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
|
.set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
|
||||||
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
|
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
|
||||||
|
|||||||
+40
-7
@@ -23,6 +23,9 @@ import { PHASE, GEO } from '/shared/constants.js';
|
|||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const SHOT = params.get('shot') === '1';
|
const SHOT = params.get('shot') === '1';
|
||||||
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
|
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
|
||||||
|
// the match's stage (data/stages.json id): ?stage=act1autochess_m01 boards a stage whose own devices (crates / turrets)
|
||||||
|
// stand on the prep field — the default act2autochess_m01 keeps its blowers and turrets off it (rows 6 / 8 / 13)
|
||||||
|
const STAGE_ID = params.get('stage') || 'act2autochess_m01';
|
||||||
|
|
||||||
// ---- deterministic rng ----------------------------------------------------------------------------------
|
// ---- deterministic rng ----------------------------------------------------------------------------------
|
||||||
let seed = 20260927;
|
let seed = 20260927;
|
||||||
@@ -119,7 +122,7 @@ function pushPublic() {
|
|||||||
function buildState() {
|
function buildState() {
|
||||||
uidSeq = 100;
|
uidSeq = 100;
|
||||||
seed = 20260927;
|
seed = 20260927;
|
||||||
const stage = data.lookup('stages', 'act2autochess_m01');
|
const stage = data.lookup('stages', STAGE_ID) || data.lookup('stages', 'act2autochess_m01');
|
||||||
const melee = stage.deployTiles.normal.melee.slice();
|
const melee = stage.deployTiles.normal.melee.slice();
|
||||||
const ranged = stage.deployTiles.normal.rangedOnly.slice();
|
const ranged = stage.deployTiles.normal.rangedOnly.slice();
|
||||||
const bonds = data.list('bonds');
|
const bonds = data.list('bonds');
|
||||||
@@ -206,7 +209,7 @@ function buildState() {
|
|||||||
|
|
||||||
const pub = {
|
const pub = {
|
||||||
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
|
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
|
||||||
stageId: 'act2autochess_m01', factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
|
stageId: STAGE_ID, factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
|
||||||
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
|
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
|
||||||
};
|
};
|
||||||
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
|
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
|
||||||
@@ -472,6 +475,13 @@ function startCombat(phase) {
|
|||||||
const e = data.lookup('enemies', bossRec.enemyKey);
|
const e = data.lookup('enemies', bossRec.enemyKey);
|
||||||
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
|
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
|
||||||
}
|
}
|
||||||
|
// ?variant=devices — the stage's crates inside the rect are device UNITS (kind 'device', defId = the device key), as in the
|
||||||
|
// real sim: absent from the field meta (a battle shown from its start), they come in with 'spawn' events once it steps;
|
||||||
|
// __MOCK__.breakDevice(i) destroys one (hp 0 for a second in the snapshots, then gone)
|
||||||
|
const deviceUnits = !VARIANTS.has('devices') ? [] : (S.stage.devices || [])
|
||||||
|
.filter((d) => d.role === 'crate' && (typeof d.active === 'boolean' ? d.active : !d.hidden)
|
||||||
|
&& d.pos[0] >= rect.r0 && d.pos[0] <= rect.r1 && d.pos[1] >= rect.c0 && d.pos[1] <= rect.c1)
|
||||||
|
.map((d) => ({ id: id++, kind: 'device', side: 'ally', ownerId: null, defId: d.key, name: d.name, x: d.pos[1], y: d.pos[0], facing: 1, maxHp: 100, hp: 100, deadAt: null }));
|
||||||
// every field — 联防 too — is fought on the round's battlefield (server unite.js; 0.2.0's escaped-level map withdrawn)
|
// every field — 联防 too — is fought on the round's battlefield (server unite.js; 0.2.0's escaped-level map withdrawn)
|
||||||
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
|
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
|
||||||
store.patch('match', { field });
|
store.patch('match', { field });
|
||||||
@@ -489,6 +499,7 @@ function startCombat(phase) {
|
|||||||
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
|
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
|
||||||
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
|
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
|
||||||
const ev = [];
|
const ev = [];
|
||||||
|
for (const d of deviceUnits) if (!spawned.has(d.id)) { spawned.add(d.id); ev.push(['spawn', { ...d }]); }
|
||||||
for (const e of enemies) {
|
for (const e of enemies) {
|
||||||
if (e.dead || t < e.spawnAt) continue;
|
if (e.dead || t < e.spawnAt) continue;
|
||||||
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
|
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
|
||||||
@@ -508,6 +519,7 @@ function startCombat(phase) {
|
|||||||
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
|
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
|
||||||
const snapUnits = [
|
const snapUnits = [
|
||||||
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
|
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
|
||||||
|
...deviceUnits.filter((d) => d.deadAt == null || t - d.deadAt < 1).map((d) => [d.id, d.x, d.y, Math.max(0, d.hp), d.maxHp, 0, 0, 0, 2]),
|
||||||
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
|
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
|
||||||
];
|
];
|
||||||
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
|
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
|
||||||
@@ -517,7 +529,13 @@ function startCombat(phase) {
|
|||||||
net._emit('b.snap', snap);
|
net._emit('b.snap', snap);
|
||||||
};
|
};
|
||||||
const e2boss = (e) => !!e.boss;
|
const e2boss = (e) => !!e.boss;
|
||||||
S.battle = { timer: setInterval(tick, 50), fieldRef };
|
S.battle = { timer: setInterval(tick, 50), fieldRef, devices: deviceUnits, hurtDevice: (i = 0, dmg = 100) => {
|
||||||
|
const d = deviceUnits[i];
|
||||||
|
if (!d || d.deadAt != null) return false;
|
||||||
|
d.hp = Math.max(0, d.hp - dmg);
|
||||||
|
if (d.hp <= 0) { d.deadAt = (performance.now() - t0 - pausedFor) / 1000 + 4; net._emit('b.ev', { t: 'b.ev', fieldId: fieldRef.id, gt: d.deadAt, ev: [['die', d.id]] }); }
|
||||||
|
return true;
|
||||||
|
} };
|
||||||
setTimeout(tick, 60);
|
setTimeout(tick, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +593,7 @@ async function mockRequest(t, f = {}) {
|
|||||||
prepOnly();
|
prepOnly();
|
||||||
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
|
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
|
||||||
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
|
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
|
||||||
const n = p.shop.slots.filter((s) => s.kind !== 'item').length;
|
const n = p.shop.slots.filter((s) => !s || s.kind !== 'item').length;
|
||||||
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
|
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
|
||||||
refreshPrivate(); return {};
|
refreshPrivate(); return {};
|
||||||
}
|
}
|
||||||
@@ -586,7 +604,8 @@ async function mockRequest(t, f = {}) {
|
|||||||
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
|
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
|
||||||
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
|
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
|
||||||
const n = p.shop.level >= 4 ? 5 : 4;
|
const n = p.shop.level >= 4 ? 5 : 4;
|
||||||
while (p.shop.slots.filter((s) => s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, makeSlot(p.shop.level));
|
// the upgrade opens the new slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot; a refresh fills it)
|
||||||
|
while (p.shop.slots.filter((s) => !s || s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, null);
|
||||||
toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
|
toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
|
||||||
refreshPrivate(); return {};
|
refreshPrivate(); return {};
|
||||||
}
|
}
|
||||||
@@ -706,6 +725,15 @@ async function mockRequest(t, f = {}) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
|
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
|
||||||
|
// the host frees a spectator seat (server/lobby.js removeSpectator; ?variant=spectators seats two, ?variant=notHost makes
|
||||||
|
// another player the host)
|
||||||
|
case 'room.removeSpectator': {
|
||||||
|
const room = store.get().room;
|
||||||
|
if (room?.hostId !== ME) fail('NOT_HOST');
|
||||||
|
if (!(room.spectators || []).some((x) => x.playerId === f.playerId)) fail('BAD_TARGET');
|
||||||
|
store.set({ room: { ...room, spectators: room.spectators.filter((x) => x.playerId !== f.playerId) } });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
default: return {};
|
default: return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,7 +808,11 @@ async function boot() {
|
|||||||
me: { playerId: ME, name: '凯尔希', token: null },
|
me: { playerId: ME, name: '凯尔希', token: null },
|
||||||
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
|
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
|
||||||
clock: { offset: 0, rtt: 20, synced: true },
|
clock: { offset: 0, rtt: 20, synced: true },
|
||||||
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
|
room: {
|
||||||
|
code: 'MOCK', hostId: VARIANTS.has('notHost') ? 'ai_2' : ME, mode: 'coop', difficulty: 'HARD', inMatch: true,
|
||||||
|
seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })),
|
||||||
|
...(VARIANTS.has('spectators') ? { spectators: [{ playerId: 'sp_1', name: '观战者甲', connected: true }, { playerId: 'sp_2', name: '观战者乙', connected: false }] } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await data.loadAll(GAME_FILES);
|
await data.loadAll(GAME_FILES);
|
||||||
installDeviceSupport();
|
installDeviceSupport();
|
||||||
@@ -790,6 +822,7 @@ async function boot() {
|
|||||||
render(html`<div class="app-root"><div class="app-bg" aria-hidden="true"></div><${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} /></div>`, document.getElementById('app'));
|
render(html`<div class="app-root"><div class="app-bg" aria-hidden="true"></div><${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} /></div>`, document.getElementById('app'));
|
||||||
renderBar();
|
renderBar();
|
||||||
store.subscribe(() => renderBar());
|
store.subscribe(() => renderBar());
|
||||||
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic() };
|
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic(),
|
||||||
|
hurtDevice: (i, dmg) => S?.battle?.hurtDevice?.(i, dmg) ?? false };
|
||||||
}
|
}
|
||||||
boot().catch((err) => console.error('[mock] boot failed', err));
|
boot().catch((err) => console.error('[mock] boot failed', err));
|
||||||
@@ -685,6 +685,7 @@
|
|||||||
" · 未装备模组": " · No Module equipped",
|
" · 未装备模组": " · No Module equipped",
|
||||||
"已购买": "Purchased",
|
"已购买": "Purchased",
|
||||||
"已招募": "Recruited",
|
"已招募": "Recruited",
|
||||||
|
"空栏位:刷新或下回合开始时补满": "Empty slot: filled by the next refresh or at the start of the next round",
|
||||||
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
|
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
|
||||||
"升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
|
"升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
|
||||||
"已满级": "Max Level",
|
"已满级": "Max Level",
|
||||||
@@ -781,6 +782,7 @@
|
|||||||
"观战席": "Spectators",
|
"观战席": "Spectators",
|
||||||
"移出观战者 {name}": "Remove spectator {name}",
|
"移出观战者 {name}": "Remove spectator {name}",
|
||||||
"移出该观战者": "Remove this spectator",
|
"移出该观战者": "Remove this spectator",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "Spectators take no Doctor seat and can only watch; the Host can remove a spectator, who then no longer receives the battlefield.",
|
||||||
"入座": "Take a Seat",
|
"入座": "Take a Seat",
|
||||||
"移出同盟": "Remove from Alliance",
|
"移出同盟": "Remove from Alliance",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
|
||||||
@@ -916,6 +918,22 @@
|
|||||||
"地面单位可通过": "Ground units can pass",
|
"地面单位可通过": "Ground units can pass",
|
||||||
"地形机制": "Terrain Mechanics",
|
"地形机制": "Terrain Mechanics",
|
||||||
"这一格": "This Tile",
|
"这一格": "This Tile",
|
||||||
|
"场地装置": "Stage Device",
|
||||||
|
"装置机制": "Device Mechanics",
|
||||||
|
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "An obstacle on the ground: ground enemies walk around it, and only run into it when there is no other way through, destroying it before moving on",
|
||||||
|
"生命 {hp} 点,被摧毁后从场上消失": "{hp} HP; it disappears from the battlefield once destroyed",
|
||||||
|
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "Automatically attacks one enemy in range for Arts damage; the enemy hit is inflicted with Fragile",
|
||||||
|
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "Scales with the Alliance that currently has the most stacks: per stack, ASPD +{aspd} (up to +{aspdMax}) and the Fragile it inflicts +{frag}% (up to +{fragMax}%)",
|
||||||
|
"脆弱持续 {sec} 秒": "The Fragile lasts {sec} {sec|second|seconds}",
|
||||||
|
"高台位装置:地面敌人不能走上这一格": "A high-ground device: ground enemies cannot walk onto this tile",
|
||||||
|
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "Ranged-position Operators can be deployed on it; an Operator standing on it is on high ground and does not block enemies",
|
||||||
|
"向前方 {n} 格吹出气流(这一台朝{dir})": "Blows an airflow {n} {n|tile|tiles} ahead (this one faces {dir})",
|
||||||
|
"朝向与风向相同的攻击力 {v}": "ATK {v} when facing the same way as the airflow",
|
||||||
|
"相反的 {v}": "{v} when facing the opposite way",
|
||||||
|
"部署在气流里的干员:{mods}": "Operators deployed in the airflow: {mods}",
|
||||||
|
"顺风移动速度 ×{v}": "Movement Speed ×{v} moving with the airflow",
|
||||||
|
"逆风 ×{v}": "×{v} moving against it",
|
||||||
|
"在气流里移动的敌人:{mods}": "Enemies moving in the airflow: {mods}",
|
||||||
"全部清空": "Clear All",
|
"全部清空": "Clear All",
|
||||||
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
|
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
|
||||||
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
|
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
|
||||||
|
|||||||
@@ -686,6 +686,7 @@
|
|||||||
" · 未装备模组": " · モジュール未装備",
|
" · 未装备模组": " · モジュール未装備",
|
||||||
"已购买": "購入済み",
|
"已购买": "購入済み",
|
||||||
"已招募": "獲得済み",
|
"已招募": "獲得済み",
|
||||||
|
"空栏位:刷新或下回合开始时补满": "空きスロット:更新するか次のラウンド開始時に補充されます",
|
||||||
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
|
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
|
||||||
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
|
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
|
||||||
"已满级": "最大レベル",
|
"已满级": "最大レベル",
|
||||||
@@ -782,6 +783,7 @@
|
|||||||
"观战席": "観戦席",
|
"观战席": "観戦席",
|
||||||
"移出观战者 {name}": "観戦者{name}を除外",
|
"移出观战者 {name}": "観戦者{name}を除外",
|
||||||
"移出该观战者": "この観戦者を除外",
|
"移出该观战者": "この観戦者を除外",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
|
||||||
"入座": "着席",
|
"入座": "着席",
|
||||||
"移出同盟": "同盟から除外",
|
"移出同盟": "同盟から除外",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
|
||||||
@@ -916,6 +918,22 @@
|
|||||||
"地面单位可通过": "地上ユニットも通過可能",
|
"地面单位可通过": "地上ユニットも通過可能",
|
||||||
"地形机制": "地形の仕組み",
|
"地形机制": "地形の仕組み",
|
||||||
"这一格": "このマス",
|
"这一格": "このマス",
|
||||||
|
"场地装置": "ステージ装置",
|
||||||
|
"装置机制": "装置の仕組み",
|
||||||
|
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "地面に置かれた障害物:地上の敵は迂回して進み、他に道がないときだけぶつかって破壊し、そのまま進む",
|
||||||
|
"生命 {hp} 点,被摧毁后从场上消失": "HP {hp}。破壊されると戦場から消える",
|
||||||
|
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "射程内の敵1体を自動で攻撃して術ダメージを与え、命中した敵に脆弱を付与する",
|
||||||
|
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "加算数が最も多い盟約に応じて強化:加算数1ごとに攻撃速度+{aspd}(最大+{aspdMax})、付与する脆弱+{frag}%(最大+{fragMax}%)",
|
||||||
|
"脆弱持续 {sec} 秒": "脆弱は{sec}秒持続する",
|
||||||
|
"高台位装置:地面敌人不能走上这一格": "高台の装置:地上の敵はこのマスに入れない",
|
||||||
|
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠距離位置のオペレーターを配置できる。上に立つオペレーターは高所にいるため、敵をブロックしない",
|
||||||
|
"向前方 {n} 格吹出气流(这一台朝{dir})": "前方{n}マスに気流を吹き出す(この装置は{dir}向き)",
|
||||||
|
"朝向与风向相同的攻击力 {v}": "気流と同じ向きの攻撃力{v}",
|
||||||
|
"相反的 {v}": "逆向きは{v}",
|
||||||
|
"部署在气流里的干员:{mods}": "気流内に配置されたオペレーター:{mods}",
|
||||||
|
"顺风移动速度 ×{v}": "気流と同じ向きに移動すると移動速度×{v}",
|
||||||
|
"逆风 ×{v}": "逆向きは×{v}",
|
||||||
|
"在气流里移动的敌人:{mods}": "気流内を移動する敵:{mods}",
|
||||||
"全部清空": "すべてクリア",
|
"全部清空": "すべてクリア",
|
||||||
"清空全部 {n} 个自选名额?": "自由編成枠{n}つをすべてクリアしますか?",
|
"清空全部 {n} 个自选名额?": "自由編成枠{n}つをすべてクリアしますか?",
|
||||||
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
|
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
|
||||||
|
|||||||
@@ -682,6 +682,7 @@
|
|||||||
" · 未装备模组": " · 모듈 미장착",
|
" · 未装备模组": " · 모듈 미장착",
|
||||||
"已购买": "구매 완료",
|
"已购买": "구매 완료",
|
||||||
"已招募": "구매 완료",
|
"已招募": "구매 완료",
|
||||||
|
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
|
||||||
"再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})",
|
"再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})",
|
||||||
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
|
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
|
||||||
"已满级": "최대 레벨",
|
"已满级": "최대 레벨",
|
||||||
@@ -778,6 +779,7 @@
|
|||||||
"观战席": "관전석",
|
"观战席": "관전석",
|
||||||
"移出观战者 {name}": "관전자 {name} 내보내기",
|
"移出观战者 {name}": "관전자 {name} 내보내기",
|
||||||
"移出该观战者": "이 관전자 내보내기",
|
"移出该观战者": "이 관전자 내보내기",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.",
|
||||||
"入座": "착석",
|
"入座": "착석",
|
||||||
"移出同盟": "동맹에서 내보내기",
|
"移出同盟": "동맹에서 내보내기",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
|
||||||
@@ -912,6 +914,22 @@
|
|||||||
"地面单位可通过": "지상 유닛 통과 가능",
|
"地面单位可通过": "지상 유닛 통과 가능",
|
||||||
"地形机制": "지형 메커니즘",
|
"地形机制": "지형 메커니즘",
|
||||||
"这一格": "이 칸",
|
"这一格": "이 칸",
|
||||||
|
"场地装置": "스테이지 장치",
|
||||||
|
"装置机制": "장치 메커니즘",
|
||||||
|
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "지면에 놓인 구조물: 지상 적은 우회해서 이동하며, 다른 길이 없을 때만 부딪혀 파괴하고 계속 전진함",
|
||||||
|
"生命 {hp} 点,被摧毁后从场上消失": "HP {hp}, 파괴되면 전장에서 사라짐",
|
||||||
|
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "사거리 내 적 1명을 자동으로 공격해 마법 대미지를 입히고, 명중한 적에게 취약을 부여함",
|
||||||
|
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "중첩 수가 가장 높은 맹약 기준: 중첩 1회당 공격 속도 +{aspd}(최대 +{aspdMax}), 부여하는 취약 +{frag}%(최대 +{fragMax}%)",
|
||||||
|
"脆弱持续 {sec} 秒": "취약은 {sec}초간 지속됨",
|
||||||
|
"高台位装置:地面敌人不能走上这一格": "고지 장치: 지상 적은 이 칸에 올라갈 수 없음",
|
||||||
|
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "원거리 위치 오퍼레이터를 배치할 수 있으며, 위에 선 오퍼레이터는 고지에 있어 적을 저지하지 않음",
|
||||||
|
"向前方 {n} 格吹出气流(这一台朝{dir})": "전방 {n}칸으로 기류를 내뿜음(이 장치는 {dir} 방향)",
|
||||||
|
"朝向与风向相同的攻击力 {v}": "기류와 같은 방향을 향한 공격력 {v}",
|
||||||
|
"相反的 {v}": "반대 방향은 {v}",
|
||||||
|
"部署在气流里的干员:{mods}": "기류 속에 배치된 오퍼레이터: {mods}",
|
||||||
|
"顺风移动速度 ×{v}": "기류 방향으로 이동 시 이동 속도 ×{v}",
|
||||||
|
"逆风 ×{v}": "역방향은 ×{v}",
|
||||||
|
"在气流里移动的敌人:{mods}": "기류 속을 이동하는 적: {mods}",
|
||||||
"全部清空": "모두 비우기",
|
"全部清空": "모두 비우기",
|
||||||
"清空全部 {n} 个自选名额?": "자유 선택 슬롯 {n}개를 모두 비우시겠습니까?",
|
"清空全部 {n} 个自选名额?": "자유 선택 슬롯 {n}개를 모두 비우시겠습니까?",
|
||||||
"文件过大,请选择「导出」下载的自选编队文件": "파일이 너무 큽니다. [내보내기]로 다운로드한 자유 편성 파일을 선택해 주세요.",
|
"文件过大,请选择「导出」下载的自选编队文件": "파일이 너무 큽니다. [내보내기]로 다운로드한 자유 편성 파일을 선택해 주세요.",
|
||||||
|
|||||||
@@ -684,6 +684,7 @@
|
|||||||
" · 未装备模组": " · 未裝備模組",
|
" · 未装备模组": " · 未裝備模組",
|
||||||
"已购买": "已購買",
|
"已购买": "已購買",
|
||||||
"已招募": "已招募",
|
"已招募": "已招募",
|
||||||
|
"空栏位:刷新或下回合开始时补满": "空欄位:更新或下回合開始時補滿",
|
||||||
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
|
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
|
||||||
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
|
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
|
||||||
"已满级": "已滿級",
|
"已满级": "已滿級",
|
||||||
@@ -780,6 +781,7 @@
|
|||||||
"观战席": "觀戰席",
|
"观战席": "觀戰席",
|
||||||
"移出观战者 {name}": "移出觀戰者 {name}",
|
"移出观战者 {name}": "移出觀戰者 {name}",
|
||||||
"移出该观战者": "移出該觀戰者",
|
"移出该观战者": "移出該觀戰者",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
|
||||||
"入座": "入座",
|
"入座": "入座",
|
||||||
"移出同盟": "移出同盟",
|
"移出同盟": "移出同盟",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
|
||||||
@@ -915,6 +917,22 @@
|
|||||||
"地面单位可通过": "地面單位可通過",
|
"地面单位可通过": "地面單位可通過",
|
||||||
"地形机制": "地形機制",
|
"地形机制": "地形機制",
|
||||||
"这一格": "這一格",
|
"这一格": "這一格",
|
||||||
|
"场地装置": "場地裝置",
|
||||||
|
"装置机制": "裝置機制",
|
||||||
|
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "擋在地面上的工事:地面敵人會繞開它走;只有無路可走時才會撞上來,把它摧毀後繼續前進",
|
||||||
|
"生命 {hp} 点,被摧毁后从场上消失": "生命 {hp} 點,被摧毀後從場上消失",
|
||||||
|
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "自動攻擊射程內的一名敵人,造成法術傷害,命中的敵人附加脆弱",
|
||||||
|
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "按目前層數最高的盟約計算:每 1 層,攻擊速度 +{aspd}(最多 +{aspdMax}),附加的脆弱 +{frag}%(最多 +{fragMax}%)",
|
||||||
|
"脆弱持续 {sec} 秒": "脆弱持續 {sec} 秒",
|
||||||
|
"高台位装置:地面敌人不能走上这一格": "高台位裝置:地面敵人不能走上這一格",
|
||||||
|
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠程位幹員可以部署在其上;站上去的幹員在高位,不阻擋敵人",
|
||||||
|
"向前方 {n} 格吹出气流(这一台朝{dir})": "向前方 {n} 格吹出氣流(這一台朝{dir})",
|
||||||
|
"朝向与风向相同的攻击力 {v}": "朝向與風向相同的攻擊力 {v}",
|
||||||
|
"相反的 {v}": "相反的 {v}",
|
||||||
|
"部署在气流里的干员:{mods}": "部署在氣流裡的幹員:{mods}",
|
||||||
|
"顺风移动速度 ×{v}": "順風移動速度 ×{v}",
|
||||||
|
"逆风 ×{v}": "逆風 ×{v}",
|
||||||
|
"在气流里移动的敌人:{mods}": "在氣流裡移動的敵人:{mods}",
|
||||||
"全部清空": "全部清空",
|
"全部清空": "全部清空",
|
||||||
"清空全部 {n} 个自选名额?": "清空全部 {n} 個自選名額?",
|
"清空全部 {n} 个自选名额?": "清空全部 {n} 個自選名額?",
|
||||||
"文件过大,请选择「导出」下载的自选编队文件": "檔案過大,請選擇「匯出」下載的自選編隊檔案",
|
"文件过大,请选择「导出」下载的自选编队文件": "檔案過大,請選擇「匯出」下載的自選編隊檔案",
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function followedScout({ field = null, watching = null, alive = true, spe
|
|||||||
return fid;
|
return fid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
|
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, resolved, total, done, isBot }]. */
|
||||||
export function teammateProgress(pub, myId) {
|
export function teammateProgress(pub, myId) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const f of fields(pub)) {
|
for (const f of fields(pub)) {
|
||||||
@@ -111,7 +111,9 @@ export function teammateProgress(pub, myId) {
|
|||||||
const pr = isObj(f.progress) ? f.progress : null;
|
const pr = isObj(f.progress) ? f.progress : null;
|
||||||
out.push({
|
out.push({
|
||||||
playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot,
|
playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot,
|
||||||
killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
|
killed: Number.isFinite(pr?.killed) ? pr.killed : null,
|
||||||
|
resolved: Number.isFinite(pr?.resolved) ? pr.resolved : (Number.isFinite(pr?.killed) ? pr.killed : null),
|
||||||
|
total: Number.isFinite(pr?.total) ? pr.total : null,
|
||||||
done: f.live === false || !!pr?.done,
|
done: f.live === false || !!pr?.done,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIME
|
|||||||
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
|
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
|
||||||
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
|
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the runner compares between two looks at a 联防 battle (noteUniteLeft) before it reads the leakers' enemies still
|
||||||
|
* standing again: a knock-out, a leak, a spawn, the end. `total` stands for the spawns no longer — a split child or a summon
|
||||||
|
* is outside the capsule's denominator (DESIGN §14 顶栏胶囊) — so the length of the enemy list does.
|
||||||
|
* @param {any} b the battle
|
||||||
|
*/
|
||||||
|
export const uniteLeftMark = (b) => `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${Array.isArray(b.enemies) ? b.enemies.length : 0}:${b.finished ? 1 : 0}`;
|
||||||
|
|
||||||
function deepFreeze(root) {
|
function deepFreeze(root) {
|
||||||
const stack = [root];
|
const stack = [root];
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
@@ -292,7 +300,7 @@ export function createBattleRunner(deps) {
|
|||||||
|
|
||||||
function noteUniteLeft(e) {
|
function noteUniteLeft(e) {
|
||||||
const b = e.battle;
|
const b = e.battle;
|
||||||
const mark = `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${b.finished ? 1 : 0}`;
|
const mark = uniteLeftMark(b);
|
||||||
if (mark === e.leakMark) return;
|
if (mark === e.leakMark) return;
|
||||||
e.leakMark = mark;
|
e.leakMark = mark;
|
||||||
let left = e.left;
|
let left = e.left;
|
||||||
@@ -433,6 +441,10 @@ export function createBattleRunner(deps) {
|
|||||||
e.lastProgressAt = t;
|
e.lastProgressAt = t;
|
||||||
const p = e.sim.spec.battleProgress(e.battle);
|
const p = e.sim.spec.battleProgress(e.battle);
|
||||||
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
|
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
|
||||||
|
// the HUD capsule's numerator of this field (shared/protocol.js b.progress `resolved`): the field's own scheduled
|
||||||
|
// enemies knocked out or leaked. Sent only when the battle reports one (Battle.resolved) — an absent field leaves
|
||||||
|
// the teammate UI on its `resolved ?? killed` fallback
|
||||||
|
if (Number.isFinite(p.resolved)) msg.resolved = Math.max(0, Math.min(msg.total, p.resolved));
|
||||||
if (bossLike(e)) {
|
if (bossLike(e)) {
|
||||||
const pool = e.battle.sharedBoss;
|
const pool = e.battle.sharedBoss;
|
||||||
msg.leaks = Math.min(1e6, e.meter.lp);
|
msg.leaks = Math.min(1e6, e.meter.lp);
|
||||||
|
|||||||
@@ -96,10 +96,10 @@ import {
|
|||||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||||
battleOverSfx, uniteResultBox, battleResultBox,
|
battleOverSfx, uniteResultBox, battleResultBox,
|
||||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||||
terrainInfo,
|
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
|
||||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
|
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
|
||||||
} from '../ui/gameLogic.js';
|
} from '../ui/gameLogic.js';
|
||||||
import { toast } from '../ui/toasts.js';
|
import { toast } from '../ui/toasts.js';
|
||||||
import { BriefingScreen } from './briefing.js';
|
import { BriefingScreen } from './briefing.js';
|
||||||
@@ -146,6 +146,9 @@ export function GameScreen() {
|
|||||||
|
|
||||||
// ---- match screen ----------------------------------------------------------------------------------------
|
// ---- match screen ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
|
||||||
|
const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
|
||||||
|
|
||||||
function MatchScreen() {
|
function MatchScreen() {
|
||||||
useDocClass('sp-in-match');
|
useDocClass('sp-in-match');
|
||||||
const pub = useStore((s) => s.match.public);
|
const pub = useStore((s) => s.match.public);
|
||||||
@@ -156,6 +159,12 @@ function MatchScreen() {
|
|||||||
const emotes = useStore((s) => s.emotes);
|
const emotes = useStore((s) => s.emotes);
|
||||||
const roomSolo = useStore((s) => s.room?.mode === 'solo');
|
const roomSolo = useStore((s) => s.room?.mode === 'solo');
|
||||||
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
||||||
|
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
|
||||||
|
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
|
||||||
|
const specFacts = useStore((s) => ({
|
||||||
|
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
|
||||||
|
isHost: !!s.room && s.room.hostId === s.me.playerId,
|
||||||
|
}), shallowEqual);
|
||||||
const gd = useGameData();
|
const gd = useGameData();
|
||||||
|
|
||||||
const hostRef = useRef(null);
|
const hostRef = useRef(null);
|
||||||
@@ -289,6 +298,18 @@ function MatchScreen() {
|
|||||||
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
|
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
|
||||||
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
|
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
|
||||||
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
|
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
|
||||||
|
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
|
||||||
|
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
|
||||||
|
// the bar is folded for itself: the state it returns to is the one that changes.
|
||||||
|
const readySeen = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
|
||||||
|
const change = readyShopFold(readySeen.current, cur);
|
||||||
|
readySeen.current = cur;
|
||||||
|
if (!change) return;
|
||||||
|
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
|
||||||
|
else setCollapsed(change === 'fold');
|
||||||
|
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
|
||||||
const cancelFacingRef = useRef(() => {});
|
const cancelFacingRef = useRef(() => {});
|
||||||
const setCam = useCallback((kind, opts) => {
|
const setCam = useCallback((kind, opts) => {
|
||||||
camRef.current = { kind, opts: opts || {} };
|
camRef.current = { kind, opts: opts || {} };
|
||||||
@@ -338,6 +359,9 @@ function MatchScreen() {
|
|||||||
const lastFieldRef = useRef(null);
|
const lastFieldRef = useRef(null);
|
||||||
const viewModeRef = useRef(null);
|
const viewModeRef = useRef(null);
|
||||||
const snapUnitsRef = useRef(new Map());
|
const snapUnitsRef = useRef(new Map());
|
||||||
|
// the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
|
||||||
|
// for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
|
||||||
|
const deviceUnitsRef = useRef(new Map());
|
||||||
const hudRef = useRef(null);
|
const hudRef = useRef(null);
|
||||||
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
|
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
|
||||||
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
|
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
|
||||||
@@ -375,6 +399,9 @@ function MatchScreen() {
|
|||||||
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
|
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
|
||||||
? (row, col) => fieldTile(deployField, row, col)
|
? (row, col) => fieldTile(deployField, row, col)
|
||||||
: (row, col) => [row, col];
|
: (row, col) => [row, col];
|
||||||
|
// a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
|
||||||
|
// unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
|
||||||
|
live.current.deviceBattle = !!shownField && !shownField.prep;
|
||||||
const staleFieldRef = useRef(null);
|
const staleFieldRef = useRef(null);
|
||||||
const enteredFieldRef = useRef(null);
|
const enteredFieldRef = useRef(null);
|
||||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||||
@@ -405,7 +432,7 @@ function MatchScreen() {
|
|||||||
const wanted = combat || mode === 'settle' || watchingOther;
|
const wanted = combat || mode === 'settle' || watchingOther;
|
||||||
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
|
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
|
||||||
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
|
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
|
||||||
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
|
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
|
||||||
enteredFieldRef.current = field;
|
enteredFieldRef.current = field;
|
||||||
lastFieldRef.current = field.fieldId;
|
lastFieldRef.current = field.fieldId;
|
||||||
reentryRef.current = null;
|
reentryRef.current = null;
|
||||||
@@ -414,6 +441,7 @@ function MatchScreen() {
|
|||||||
view.enterBattle(field);
|
view.enterBattle(field);
|
||||||
const early = evBufRef.current.get(field.fieldId);
|
const early = evBufRef.current.get(field.fieldId);
|
||||||
evBufRef.current.delete(field.fieldId);
|
evBufRef.current.delete(field.fieldId);
|
||||||
|
deviceUnitsRef.current = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||||
const earlySnap = snapBufRef.current.get(field.fieldId);
|
const earlySnap = snapBufRef.current.get(field.fieldId);
|
||||||
snapBufRef.current.delete(field.fieldId);
|
snapBufRef.current.delete(field.fieldId);
|
||||||
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
|
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
|
||||||
@@ -501,6 +529,7 @@ function MatchScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
view?.pushEvents(msg);
|
view?.pushEvents(msg);
|
||||||
|
noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
|
||||||
audio.handleBattleEvents(msg.ev);
|
audio.handleBattleEvents(msg.ev);
|
||||||
};
|
};
|
||||||
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
|
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
|
||||||
@@ -633,7 +662,7 @@ function MatchScreen() {
|
|||||||
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
|
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
|
||||||
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
|
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
|
||||||
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
|
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
|
||||||
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (d?.kind === 'unit' ? null : d));
|
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
|
||||||
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
|
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
|
||||||
setSel(null);
|
setSel(null);
|
||||||
if (phase !== PHASE.PREP) setRewardMin(false);
|
if (phase !== PHASE.PREP) setRewardMin(false);
|
||||||
@@ -697,6 +726,7 @@ function MatchScreen() {
|
|||||||
|
|
||||||
// ---- actions ----------------------------------------------------------------------------------------------
|
// ---- actions ----------------------------------------------------------------------------------------------
|
||||||
const buy = useCallback((i) => actions.buy(i), []);
|
const buy = useCallback((i) => actions.buy(i), []);
|
||||||
|
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
|
||||||
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
|
// 准备 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.
|
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
|
||||||
const askingReady = useRef(false);
|
const askingReady = useRef(false);
|
||||||
@@ -931,13 +961,24 @@ function MatchScreen() {
|
|||||||
setSel(wasSel ? null : { uid: e.uid });
|
setSel(wasSel ? null : { uid: e.uid });
|
||||||
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
|
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
|
||||||
}),
|
}),
|
||||||
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
|
// a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
|
||||||
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
|
// #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
|
||||||
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
|
// tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
|
||||||
|
// 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
|
||||||
|
// press keeps its other meanings (deselect, close).
|
||||||
view.on('tileClick', (t) => {
|
view.on('tileClick', (t) => {
|
||||||
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
|
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
|
||||||
const L = live.current;
|
const L = live.current;
|
||||||
const [row, col] = L.terrainTile(t.row, t.col);
|
const [row, col] = L.terrainTile(t.row, t.col);
|
||||||
|
const device = L.deviceBattle
|
||||||
|
? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
|
||||||
|
: deviceInfo(L.terrainStage, row, col);
|
||||||
|
if (device) {
|
||||||
|
audio.sfx('click', { volume: 0.4 });
|
||||||
|
setSel(null);
|
||||||
|
setDetail({ kind: 'device', device });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const info = terrainInfo(L.terrainStage, row, col);
|
const info = terrainInfo(L.terrainStage, row, col);
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
audio.sfx('click', { volume: 0.4 });
|
audio.sfx('click', { volume: 0.4 });
|
||||||
@@ -1110,6 +1151,13 @@ function MatchScreen() {
|
|||||||
const t = id != null ? snapUnitsRef.current.get(id) : null;
|
const t = id != null ? snapUnitsRef.current.get(id) : null;
|
||||||
return t ? { hp: t[3], max: t[4] } : null;
|
return t ? { hp: t[3], max: t[4] } : null;
|
||||||
})();
|
})();
|
||||||
|
// a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
|
||||||
|
// is over — before the first snapshot of a battle nothing is judged)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = detail?.kind === 'device' ? detail.device?.unitId : null;
|
||||||
|
const snap = snapUnitsRef.current;
|
||||||
|
if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
|
||||||
|
}, [hud, detail]);
|
||||||
|
|
||||||
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
|
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
|
||||||
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
|
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
|
||||||
@@ -1325,7 +1373,8 @@ function MatchScreen() {
|
|||||||
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
|
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
|
||||||
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
|
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
|
||||||
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
|
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
|
||||||
live=${liveLpNow} spectator=${spectator} />
|
live=${liveLpNow} spectator=${spectator}
|
||||||
|
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
|
||||||
|
|
||||||
<div class="gm__bonds">
|
<div class="gm__bonds">
|
||||||
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame
|
|||||||
import { toast, toastError } from '../ui/toasts.js';
|
import { toast, toastError } from '../ui/toasts.js';
|
||||||
import { GuideButton } from '../ui/guide.js';
|
import { GuideButton } from '../ui/guide.js';
|
||||||
import { openStats } from './stats.js';
|
import { openStats } from './stats.js';
|
||||||
|
import { SettingsButton } from '../ui/settings.js';
|
||||||
import { LoadoutButton } from './loadout.js';
|
import { LoadoutButton } from './loadout.js';
|
||||||
import { net, identity } from '../net.js';
|
import { net, identity } from '../net.js';
|
||||||
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
||||||
@@ -313,6 +314,7 @@ export function LobbyScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar__right">
|
<div class="topbar__right">
|
||||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||||
|
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
|
||||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||||
<div class="me-chip">
|
<div class="me-chip">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { toast, toastError } from '../ui/toasts.js';
|
|||||||
import { copyText } from '../ui/clipboard.js';
|
import { copyText } from '../ui/clipboard.js';
|
||||||
import { GuideButton } from '../ui/guide.js';
|
import { GuideButton } from '../ui/guide.js';
|
||||||
import { openStats } from './stats.js';
|
import { openStats } from './stats.js';
|
||||||
|
import { SettingsButton } from '../ui/settings.js';
|
||||||
import { LoadoutButton } from './loadout.js';
|
import { LoadoutButton } from './loadout.js';
|
||||||
import { net } from '../net.js';
|
import { net } from '../net.js';
|
||||||
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
|
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
|
||||||
@@ -308,6 +309,7 @@ export function RoomScreen() {
|
|||||||
<${MicroLabel}>${t('当前延迟')}<//>
|
<${MicroLabel}>${t('当前延迟')}<//>
|
||||||
</div>
|
</div>
|
||||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||||
|
<${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} />
|
||||||
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar__center">
|
<div class="topbar__center">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function ProgressList({ list }) {
|
|||||||
<b>${p.name}</b>
|
<b>${p.name}</b>
|
||||||
${p.done
|
${p.done
|
||||||
? html`<span class="chud__prog__ok" aria-label=${t('作战结束')}>✓</span>`
|
? html`<span class="chud__prog__ok" aria-label=${t('作战结束')}>✓</span>`
|
||||||
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
|
: html`<span class="num">${(p.resolved ?? p.killed) != null && p.total != null ? `${p.resolved ?? p.killed}/${p.total}` : '•••'}</span>`}
|
||||||
</span>`)}
|
</span>`)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -711,9 +711,36 @@ function TerrainDetail({ terrain }) {
|
|||||||
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
|
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stage device's tip (GitHub #228, PR #229: 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 — the terrain tip's sibling). Opened
|
||||||
|
* by a tap on the device itself: the game screen resolves it with `gameLogic.deviceInfo` (prep) or `deviceTipAt` (a battle)
|
||||||
|
* from the stage the board on screen is built from, so the lines carry that stage's own numbers. In a battle the
|
||||||
|
* crate / turret is a device unit: its live HP (`live` from the battle's own sim, else `snapHp` — the screen reads the latest
|
||||||
|
* snapshot tuple through `unitId`) draws a HP bar like a unit card's.
|
||||||
|
* @param {{ name:string, tag:string, lines:string[], facts?:string[], stats?:{k:string,v:any}[] }} device
|
||||||
|
* @param {{ hp:number, max:number }|null} snapHp
|
||||||
|
*/
|
||||||
|
function DeviceDetail({ device, snapHp = null, live = null }) {
|
||||||
|
const hp = hpOf(live, snapHp);
|
||||||
|
return html`
|
||||||
|
<div class="dhead">
|
||||||
|
<div class="dhead__icon"><${Icon} name="info" /></div>
|
||||||
|
<div class="dhead__info">
|
||||||
|
<div class="dhead__chips"><span class="dtag-kind">${device.tag}</span></div>
|
||||||
|
<h3 class="dhead__name">${device.name}</h3>
|
||||||
|
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<${Section} title=${t('装置机制')} micro="DEVICE">
|
||||||
|
${device.lines.map((line, i) => html`<p class="dtext" key=${i}>${line}</p>`)}
|
||||||
|
<//>
|
||||||
|
${Array.isArray(device.stats) && device.stats.length ? html`<div class="dstats">${device.stats.map((x) => html`<${Stat} key=${x.k} k=${x.k} v=${x.v} />`)}</div>` : null}
|
||||||
|
${Array.isArray(device.facts) && device.facts.length ? html`<${Section} title=${t('这一格')}><p class="dtext">${device.facts.join(' · ')}</p><//>` : null}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve what a detail target shows.
|
* Resolve what a detail target shows.
|
||||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
|
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain'|'device', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||||
* @param {Map<number, any>} pieces indexPieces(priv)
|
* @param {Map<number, any>} pieces indexPieces(priv)
|
||||||
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
|
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
|
||||||
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
|
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
|
||||||
@@ -726,6 +753,12 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
|||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
|
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
|
||||||
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
|
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
|
||||||
|
// a stage device (#228): the screen resolved the device's stage entry (gameLogic.deviceInfo / deviceTipAt); in a battle
|
||||||
|
// `unitId` is the crate / turret unit whose snapshot tuple gives the live HP
|
||||||
|
if (target.kind === 'device') {
|
||||||
|
const device = target.device && typeof target.device === 'object' ? target.device : null;
|
||||||
|
return device ? { type: 'device', device, ...(Number.isInteger(device.unitId) ? { unitId: device.unitId } : {}) } : null;
|
||||||
|
}
|
||||||
const ownSi = (c) => ownStandIn(c, priv, backups);
|
const ownSi = (c) => ownStandIn(c, priv, backups);
|
||||||
const dd = { chess: data.get('chess'), backups };
|
const dd = { chess: data.get('chess'), backups };
|
||||||
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
|
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
|
||||||
@@ -855,6 +888,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
|||||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||||
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
|
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
|
||||||
|
${detail.type === 'device' ? html`<${DeviceDetail} device=${detail.device} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>`;
|
</aside>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,7 @@ export const actions = {
|
|||||||
autoplay: (on) => act('g.autoplay', { on }),
|
autoplay: (on) => act('g.autoplay', { on }),
|
||||||
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
|
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
|
||||||
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
|
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
|
||||||
|
// room-level intent (NOT g.*): the host frees a spectator seat while the match runs — the server takes room.removeSpectator at
|
||||||
|
// any time (server/lobby.js removeSpectator), the game screen had no entry for it (ui/hud.js SpectatorPill; GitHub #120)
|
||||||
|
removeSpectator: (playerId) => act('room.removeSpectator', { playerId }, { sfx: 'back' }),
|
||||||
};
|
};
|
||||||
@@ -33,9 +33,9 @@ export { STATUS_META } from './gameLogic/format.js';
|
|||||||
export { sortedPlayers } from './gameLogic/shared.js';
|
export { sortedPlayers } from './gameLogic/shared.js';
|
||||||
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
|
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
|
||||||
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
|
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
|
||||||
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
|
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } from './gameLogic/shop.js';
|
||||||
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
|
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
|
||||||
export { terrainInfo } from './gameLogic/terrain.js';
|
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
|
||||||
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export const STATUS_META = Object.freeze({
|
|||||||
// ---- snapshots / battle HUD ---------------------------------------------------------------------------------
|
// ---- snapshots / battle HUD ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HUD numbers from a b.snap: { killed, total, dp, boss } (boss: { hp, max } when present).
|
* HUD numbers from a b.snap: { killed, resolved, total, dp, boss } (boss: { hp, max } when present).
|
||||||
|
* `resolved` is the capsule's numerator — knocked out + leaked among the round's own enemies (Battle.leakedInTotal);
|
||||||
|
* an older snapshot without it falls back to the kill count.
|
||||||
* @param {any} snap
|
* @param {any} snap
|
||||||
*/
|
*/
|
||||||
export function snapHud(snap) {
|
export function snapHud(snap) {
|
||||||
@@ -30,7 +32,8 @@ export function snapHud(snap) {
|
|||||||
const n = (v) => (Number.isFinite(v) ? v : null);
|
const n = (v) => (Number.isFinite(v) ? v : null);
|
||||||
let boss = null;
|
let boss = null;
|
||||||
if (isObj(snap.boss) && Number.isFinite(snap.boss.hp)) boss = { hp: snap.boss.hp, max: n(snap.boss.max) ?? n(snap.boss.maxHp) };
|
if (isObj(snap.boss) && Number.isFinite(snap.boss.hp)) boss = { hp: snap.boss.hp, max: n(snap.boss.max) ?? n(snap.boss.maxHp) };
|
||||||
return { killed: n(snap.killed), total: n(snap.total), dp: n(snap.dp), boss };
|
const killed = n(snap.killed);
|
||||||
|
return { killed, resolved: n(snap.resolved) ?? killed, total: n(snap.total), dp: n(snap.dp), boss };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Boss HP fraction 0..1 (null when unknown). */
|
/** Boss HP fraction 0..1 (null when unknown). */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt. Re-exported from ../gameLogic.js.
|
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt, the shop bar's fold on 准备. Re-exported from
|
||||||
|
// ../gameLogic.js.
|
||||||
|
|
||||||
import { GEO } from '../../../../shared/constants.js';
|
import { GEO } from '../../../../shared/constants.js';
|
||||||
import { isObj } from './shared.js';
|
import { isObj } from './shared.js';
|
||||||
@@ -183,3 +184,18 @@ export function readyFundsPrompt(priv, { ready = true, keptBands = null, autopla
|
|||||||
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
|
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shop bar follows the player's ready state (GitHub #138, the owner's decision of 2026-10-07): pressing 准备就绪 folds
|
||||||
|
* the bar — the board is set, the fight is what to look at —, cancelling it unfolds the bar again. Read from the own CONFIRMED
|
||||||
|
* state (m.private.ready — a refused 准备 folds nothing) and only a change inside one prep counts: the first state seen (a
|
||||||
|
* reconnect in the middle of a prep, the prep's start), the new round's reset to not ready and anything outside a prep are
|
||||||
|
* not a press. The player can still fold / unfold by hand in between.
|
||||||
|
* @param {{ round: any, ready: boolean }|null|undefined} prev the own prep state seen last (null: none — outside a prep)
|
||||||
|
* @param {{ round: any, ready: boolean }|null|undefined} cur the own prep state now (null outside a prep)
|
||||||
|
* @returns {'fold'|'unfold'|null}
|
||||||
|
*/
|
||||||
|
export function readyShopFold(prev, cur) {
|
||||||
|
if (!isObj(prev) || !isObj(cur) || prev.round !== cur.round || !!prev.ready === !!cur.ready) return null;
|
||||||
|
return cur.ready ? 'fold' : 'unfold';
|
||||||
|
}
|
||||||
@@ -169,9 +169,10 @@ export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || a
|
|||||||
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||||
* @param {{ kind?: string }|null|undefined} detail
|
* @param {{ kind?: string }|null|undefined} detail
|
||||||
*/
|
*/
|
||||||
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
|
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184, a stage device: #228) closes on the
|
||||||
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
|
// next press of the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that
|
||||||
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
|
// opened them)
|
||||||
|
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain' || detail?.kind === 'device';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
|
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// ui/gameLogic/terrain.js — special terrain tips (GitHub issue #184: 特殊地形的单击信息提示). Re-exported from
|
// ui/gameLogic/terrain.js — the tips of a tap on the ground: special terrain tiles (GitHub issue #184: 特殊地形的单击信息
|
||||||
// ../gameLogic.js.
|
// 提示) and, in the same place, stage devices (阻隔工事 / “双眼皮” / 射击台 / 气流: GitHub #228, PR #229 by @rickylxw —
|
||||||
|
// deviceInfo / deviceTipAt below). Re-exported from ../gameLogic.js.
|
||||||
//
|
//
|
||||||
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
|
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
|
||||||
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
|
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
|
||||||
// with their numbers as params.
|
// with their numbers as params. A device's NAME is a data text (data/stages.json `devices[].name`, localized by the
|
||||||
|
// data overlay: "Barricade", "'Double Lids'" …), not a msgid.
|
||||||
|
|
||||||
import { isObj } from './shared.js';
|
import { isObj } from './shared.js';
|
||||||
import { t, N_ } from '../../../../shared/i18n.js';
|
import { t, N_ } from '../../../../shared/i18n.js';
|
||||||
@@ -123,3 +125,174 @@ export function terrainInfo(stage, row, col) {
|
|||||||
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
|
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
|
||||||
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
|
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- stage device tips (GitHub #228, PR #229 by @rickylxw: the stage DEVICES, not only the tiles) ---------------------
|
||||||
|
|
||||||
|
/** The device roles a tap explains; the rest is invisible (盟约寒风, the 沼泽 / 涨潮 controllers) or not fielded this season. */
|
||||||
|
const DEVICE_TIP_ROLES = Object.freeze(['crate', 'turret', 'platform', 'blower']);
|
||||||
|
/** Roles the battle spawns as device UNITS (Battle._spawnStageDevices, devices.js spawnTurret): in a battle a tap finds them by unit. */
|
||||||
|
const DEVICE_UNIT_ROLES = Object.freeze(['crate', 'turret']);
|
||||||
|
const TIP_TAG = N_('场地装置');
|
||||||
|
const DIR_NAME = Object.freeze({ UP: N_('上'), DOWN: N_('下'), LEFT: N_('左'), RIGHT: N_('右') });
|
||||||
|
|
||||||
|
const round1 = (v) => Math.round(v * 10) / 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What tapping a stage device says — the same contract as TERRAIN_TIPS, but `lines` read the device's own stage entry
|
||||||
|
* (data/stages.json `stage.devices[]`: stats / skill blackboard / dir / rangeTiles), the very numbers the sim runs
|
||||||
|
* (`sim/content/devices.js`, `Battle._spawnStageDevices`), so a tip can never disagree with the battle. The prose is ours
|
||||||
|
* (docs/PLAYING.md wording; PRTS 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 and the sim's behaviour). `stats` is the
|
||||||
|
* stat grid under the lines (the turret's), `facts` what the tile allows.
|
||||||
|
*/
|
||||||
|
const DEVICE_TIPS = Object.freeze({
|
||||||
|
crate: {
|
||||||
|
lines: (st, d) => [
|
||||||
|
t('挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进'),
|
||||||
|
t('生命 {hp} 点,被摧毁后从场上消失', { hp: param(d.stats?.maxHp, 100) }),
|
||||||
|
],
|
||||||
|
facts: () => [t(BUILDABILITY.NONE)], // board.js buildDeployMap: an active crate takes its tile off the deploy map
|
||||||
|
},
|
||||||
|
turret: {
|
||||||
|
lines: (st, d) => {
|
||||||
|
const bb = isObj(d.skill?.bb) ? d.skill.bb : {};
|
||||||
|
// devices.js spawnTurret: the same blackboard keys and the same `持续N秒` read from the skill text
|
||||||
|
const dur = /持续(\d+(?:\.\d+)?)秒/.exec(String(d.skill?.desc ?? ''));
|
||||||
|
const aspdMax = param(bb.max_attack_speed, Infinity);
|
||||||
|
const fragMax = param(bb.max_damage_scale, Infinity);
|
||||||
|
return [
|
||||||
|
t('自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱'),
|
||||||
|
t('按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)', {
|
||||||
|
aspd: param(bb.attack_speed_per_stack, 1),
|
||||||
|
aspdMax: Number.isFinite(aspdMax) ? aspdMax : '∞',
|
||||||
|
frag: round1(param(bb.damage_scale_per_stack, 0.001) * 100),
|
||||||
|
fragMax: Number.isFinite(fragMax) ? round1((fragMax - 1) * 100) : '∞',
|
||||||
|
}),
|
||||||
|
dur ? t('脆弱持续 {sec} 秒', { sec: Number(dur[1]) }) : null,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
stats: (st, d) => {
|
||||||
|
const s = isObj(d.stats) ? d.stats : {};
|
||||||
|
const out = [{ k: t('生命上限'), v: param(s.maxHp, 100) }];
|
||||||
|
if (param(s.atk, 0)) out.push({ k: t('攻击'), v: param(s.atk, 0) });
|
||||||
|
if (param(s.def, 0)) out.push({ k: t('防御'), v: param(s.def, 0) });
|
||||||
|
const sec = param(s.bat, 0) > 0 ? param(s.bat, 0) * 100 / (param(s.aspd, 100) > 0 ? param(s.aspd, 100) : 100) : 0;
|
||||||
|
if (sec) out.push({ k: t('攻击间隔'), v: `${round1(sec)}s` });
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
facts: () => [t(BUILDABILITY.NONE)],
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
lines: () => [
|
||||||
|
t('高台位装置:地面敌人不能走上这一格'),
|
||||||
|
t('远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人'),
|
||||||
|
],
|
||||||
|
facts: () => [t(BUILDABILITY.RANGED)], // board.js: an active platform makes its tile a 'ranged' deploy tile
|
||||||
|
},
|
||||||
|
blower: {
|
||||||
|
lines: (st, d) => {
|
||||||
|
// devices.js buildTerrain: the device's own skill blackboard, else stage.special.blower.bb; rangeTiles = its own tile + the lane
|
||||||
|
const bb = isObj(d.skill?.bb) ? d.skill.bb : (isObj(st?.blower?.bb) ? st.blower.bb : {});
|
||||||
|
const dir = DIR_NAME[String(d.dir || 'UP').toUpperCase()] || DIR_NAME.UP;
|
||||||
|
const lane = Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 ? d.rangeTiles.length - 1 : 3;
|
||||||
|
const out = [t('向前方 {n} 格吹出气流(这一台朝{dir})', { n: lane, dir: t(dir) })];
|
||||||
|
const eq = param(bb['blower_s_character[equal].atk'], 0);
|
||||||
|
const op = param(bb['blower_s_character[opposite].atk'], 0);
|
||||||
|
const mods = [];
|
||||||
|
if (eq) mods.push(t('朝向与风向相同的攻击力 {v}', { v: pctText(eq) }));
|
||||||
|
if (op) mods.push(t('相反的 {v}', { v: pctText(op) }));
|
||||||
|
if (mods.length) out.push(t('部署在气流里的干员:{mods}', { mods }));
|
||||||
|
const eqM = param(bb['blower_s_enemy[equal].move_speed'], 0);
|
||||||
|
const opM = param(bb['blower_s_enemy[opposite].move_speed'], 0);
|
||||||
|
const em = [];
|
||||||
|
if (eqM) em.push(t('顺风移动速度 ×{v}', { v: round2(1 + eqM) }));
|
||||||
|
if (opM) em.push(t('逆风 ×{v}', { v: round2(1 + opM) }));
|
||||||
|
if (em.length) out.push(t('在气流里移动的敌人:{mods}', { mods: em }));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
facts: () => [t(BUILDABILITY.NONE)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const round2 = (v) => Math.round(v * 100) / 100;
|
||||||
|
|
||||||
|
/** A stage device entry is drawn: `active` when it carries it, else not `hidden` (render/tiles.js _stageDevices, board3d/layout.js stageDevices). */
|
||||||
|
const deviceDrawn = (d) => (typeof d.active === 'boolean' ? d.active : !d.hidden);
|
||||||
|
|
||||||
|
/** The card of stage device `d` standing at (row, col) — `tip` is DEVICE_TIPS[d.role]. */
|
||||||
|
function deviceCard(stage, d, row, col) {
|
||||||
|
const tip = DEVICE_TIPS[d.role];
|
||||||
|
const lines = tip.lines(stage.special, d).filter((s) => typeof s === 'string' && s);
|
||||||
|
const stats = typeof tip.stats === 'function' ? tip.stats(stage.special, d) : null;
|
||||||
|
const name = typeof d.name === 'string' && d.name ? d.name : String(d.role);
|
||||||
|
return {
|
||||||
|
key: d.role, name, tag: t(TIP_TAG), row, col, lines, facts: tip.facts(stage.special, d).filter(Boolean),
|
||||||
|
...(stats && stats.length ? { stats } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tip a tap on the tile (row, col) opens when a stage DEVICE stands on it (GitHub #228, PR #229), or null: an ordinary
|
||||||
|
* tile — or a device this client does not draw (`active` false: a turret that stays dormant until 机械援助 / a 机变 card
|
||||||
|
* switches it on, a crate such a card removed) — falls through to `terrainInfo`. Visibility mirrors what the renderers draw
|
||||||
|
* (`render/tiles.js _stageDevices`, `board3d/layout.js stageDevices`): `active` when set, else not `hidden`. The stage is
|
||||||
|
* the shown field's (already the player's own `effectiveStage`, so a card-removed crate is not in the way). The device's
|
||||||
|
* name is its data text (localized by the data overlay); the lines are in the current language (t()).
|
||||||
|
* @param {{ devices?: any[], special?: any, rows?: string[], tiles?: Record<string, any> } | null | undefined} stage
|
||||||
|
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
|
||||||
|
* @param {number} col
|
||||||
|
* @param {{ roles?: readonly string[] }} [opts] `roles`: only these roles answer (default: the four of DEVICE_TIP_ROLES)
|
||||||
|
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[], stats?:{k:string,v:string|number}[] } | null}
|
||||||
|
*/
|
||||||
|
export function deviceInfo(stage, row, col, { roles = DEVICE_TIP_ROLES } = {}) {
|
||||||
|
const list = Array.isArray(stage?.devices) ? stage.devices : null;
|
||||||
|
if (!list || !Number.isInteger(row) || !Number.isInteger(col)) return null;
|
||||||
|
for (const d of list) {
|
||||||
|
if (!isObj(d) || !Array.isArray(d.pos) || d.pos[0] !== row || d.pos[1] !== col) continue;
|
||||||
|
if (!roles.includes(d.role) || !DEVICE_TIPS[d.role] || !deviceDrawn(d)) continue;
|
||||||
|
return deviceCard(stage, d, row, col);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tip of a tap on (row, col) while a BATTLE shows the field — the live counterpart of `deviceInfo`: crates and “双眼皮”
|
||||||
|
* turrets are device UNITS there (kind 'device', one per drawn stage device, `defId` = the device key), so they are
|
||||||
|
* found by the unit standing on the tile — a turret outside the battle's rect stands on another tile than its stage `pos`
|
||||||
|
* (devices.js deviceTile) — and what the card says comes from the stage entry of that key. The platforms and blowers are no
|
||||||
|
* units: they answer by their stage `pos`, as in the prep.
|
||||||
|
* A destroyed device (HP gone, or no longer in the snapshots once its DIE animation is over) is not there any more: the
|
||||||
|
* tap belongs to the tile under the wreck (null here, so it falls through to `terrainInfo`). Before the first snapshot of the
|
||||||
|
* battle has arrived (`snap` empty) a unit counts as standing.
|
||||||
|
* The result is the same card as `deviceInfo`'s, with `unitId` (the unit, for its live HP: the screen reads `b.snap`
|
||||||
|
* through it, like a unit card's `snapHp`).
|
||||||
|
* @param {object|null|undefined} stage the shown field's stage
|
||||||
|
* @param {number} row @param {number} col
|
||||||
|
* @param {{ units?: any[]|null, snap?: Map<number, any[]>|null }} battle the field's unit infos (m.field.units) and the
|
||||||
|
* latest snapshot tuples by unit id ([id, x, y, hp, maxHp, …])
|
||||||
|
*/
|
||||||
|
export function deviceTipAt(stage, row, col, { units = null, snap = null } = {}) {
|
||||||
|
if (!Number.isInteger(row) || !Number.isInteger(col)) return null;
|
||||||
|
const u = (Array.isArray(units) ? units : []).find((x) => isObj(x) && x.kind === 'device' && Math.round(Number(x.x)) === col && Math.round(Number(x.y)) === row);
|
||||||
|
if (!u) return deviceInfo(stage, row, col, { roles: DEVICE_TIP_ROLES.filter((r) => !DEVICE_UNIT_ROLES.includes(r)) });
|
||||||
|
if (snap && snap.size > 0) {
|
||||||
|
const tuple = snap.get(u.id);
|
||||||
|
if (!Array.isArray(tuple) || !(tuple[3] > 0)) return null;
|
||||||
|
}
|
||||||
|
const list = Array.isArray(stage?.devices) ? stage.devices : [];
|
||||||
|
const same = list.filter((d) => isObj(d) && d.key === u.defId && DEVICE_UNIT_ROLES.includes(d.role));
|
||||||
|
const d = same.find((x) => Array.isArray(x.pos) && x.pos[0] === row && x.pos[1] === col) || same[0];
|
||||||
|
return d ? { ...deviceCard(stage, d, row, col), unitId: u.id } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the device units a battle shows into `map` (UnitInfo by unit id): the field meta's `units` (what stands when the field
|
||||||
|
* is entered) and the 'spawn' event tuples `['spawn', UnitInfo]` after it — the stage crates and the “双眼皮” turrets come
|
||||||
|
* into being with the battle's first step (`battleStart`), after the meta of a battle shown from its beginning was taken.
|
||||||
|
* Only kind 'device' is kept; `deviceTipAt` reads the map's values.
|
||||||
|
* @param {Map<number, any>} map
|
||||||
|
* @param {{ units?: any[]|null, events?: any[]|null }} [from]
|
||||||
|
*/
|
||||||
|
export function noteDeviceUnits(map, { units = null, events = null } = {}) {
|
||||||
|
for (const u of Array.isArray(units) ? units : []) if (isObj(u) && u.kind === 'device' && u.id != null) map.set(u.id, u);
|
||||||
|
for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
+52
-7
@@ -23,9 +23,9 @@
|
|||||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||||
|
|
||||||
import { useRef } from '../../vendor/hooks.module.js';
|
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||||
import { PHASE } from '../../../shared/constants.js';
|
import { PHASE } from '../../../shared/constants.js';
|
||||||
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, useTicker } from './components.js';
|
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, Modal, useTicker } from './components.js';
|
||||||
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
||||||
import { localAsset } from '../data.js';
|
import { localAsset } from '../data.js';
|
||||||
import { serverNow } from '../store.js';
|
import { serverNow } from '../store.js';
|
||||||
@@ -36,6 +36,45 @@ import { t, tParts, N_ } from '../../../shared/i18n.js';
|
|||||||
|
|
||||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 观战席 capsule of the in-match top bar (GitHub #120, PR #120 by @salt-fishes; the spectator seats of community report #26):
|
||||||
|
* the room's spectators, which the room screen lists in a strip but the game screen — the route after 开始 — used to hide
|
||||||
|
* completely, so a spectator could only be removed before the match or after it. A small eye + the count beside the latency;
|
||||||
|
* a tap opens the roster, with the host's ✕ on every row (room.removeSpectator: the server takes it at any time; the removed
|
||||||
|
* seat gets room.closed {kicked} and no more frames). Renders nothing while no spectator seat is taken.
|
||||||
|
* @param {{ spectators: any[]|null, myId: any, isHost: boolean, onRemove: ((playerId: any) => any)|null }} props
|
||||||
|
*/
|
||||||
|
export function SpectatorPill({ spectators, myId, isHost, onRemove }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const list = Array.isArray(spectators) ? spectators.filter((s) => s && typeof s === 'object') : [];
|
||||||
|
// the roster closes with its last seat (the pill is gone: it must not pop up again for the next spectator)
|
||||||
|
useEffect(() => { if (!list.length) setOpen(false); }, [list.length]);
|
||||||
|
if (!list.length) return null;
|
||||||
|
const remove = async (playerId) => {
|
||||||
|
if (!onRemove) return;
|
||||||
|
setBusy(playerId);
|
||||||
|
try { await onRemove(playerId); } finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
return html`<${Button} variant="ghost" size="sm" icon="eye" class=${cx('specpill', list.some((s) => s.playerId === myId) && 'is-me')}
|
||||||
|
onClick=${() => setOpen(true)} data-testid="spectators" title=${t('观战席')} aria-label=${t('观战席')}>
|
||||||
|
<span class="specpill__num num">${list.length}</span>
|
||||||
|
<//>
|
||||||
|
${open ? html`<${Modal} open=${true} title=${t('观战席')} micro="SPECTATORS" width="6.4rem" onClose=${() => setOpen(false)}
|
||||||
|
actions=${html`<${Button} variant="secondary" onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
|
||||||
|
<ul class="spec__roster" data-testid="spectator-roster">
|
||||||
|
${list.map((s) => html`<li key=${s.playerId} class=${cx('spec__row', s.playerId === myId && 'is-me', s.connected === false && 'is-offline')}>
|
||||||
|
<${Icon} name=${s.connected === false ? 'wifiOff' : 'eye'} class="spec__ico" />
|
||||||
|
<span class="spec__name">${s.name || t('博士')}</span>
|
||||||
|
${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
|
||||||
|
${isHost && onRemove ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === s.playerId} data-testid="spectator-remove"
|
||||||
|
onClick=${() => remove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
|
||||||
|
</li>`)}
|
||||||
|
</ul>
|
||||||
|
<p class="spec__hint t-lo">${t('观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。')}</p>
|
||||||
|
<//>` : null}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
|
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
|
||||||
* @param {{ pub:any, hud:any }} props
|
* @param {{ pub:any, hud:any }} props
|
||||||
@@ -49,7 +88,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
|
|||||||
const frac = bossFrac(boss);
|
const frac = bossFrac(boss);
|
||||||
return html`<div class="capsule capsule--boss" role="status">
|
return html`<div class="capsule capsule--boss" role="status">
|
||||||
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
|
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
|
||||||
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.killed ?? 0}</b>/${hud.total}</span>` : null}
|
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.resolved ?? hud.killed ?? 0}</b>/${hud.total}</span>` : null}
|
||||||
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : t('敌方领袖')}>
|
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : t('敌方领袖')}>
|
||||||
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
|
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
|
||||||
<span class="bossbar__txt num">${frac == null ? t('敌方领袖') : bossPctText(frac)}</span>
|
<span class="bossbar__txt num">${frac == null ? t('敌方领袖') : bossPctText(frac)}</span>
|
||||||
@@ -60,7 +99,7 @@ export function PhaseCapsule({ pub, hud, miss = null }) {
|
|||||||
return html`<div class=${cx('capsule', 'capsule--combat', phase === PHASE.UNITE && 'capsule--unite')} role="status">
|
return html`<div class=${cx('capsule', 'capsule--combat', phase === PHASE.UNITE && 'capsule--unite')} role="status">
|
||||||
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
|
<${Sprite} k=${phase === PHASE.UNITE ? 'hudPanel/icon_coop' : 'hudPanel/icon_battle'} class="capsule__icon"
|
||||||
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
|
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
|
||||||
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
|
<span class="capsule__kills num"><b>${hud?.resolved ?? hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
|
||||||
${phase === PHASE.UNITE ? html`<span class="capsule__tag">${t('联防')}</span>` : null}
|
${phase === PHASE.UNITE ? html`<span class="capsule__tag">${t('联防')}</span>` : null}
|
||||||
${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
|
${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -306,13 +345,16 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
|||||||
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
|
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
|
||||||
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
|
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
|
||||||
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
|
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
|
||||||
* live?: { pending: number, unite: boolean, left?: number|null } | null }} props
|
* live?: { pending: number, unite: boolean, left?: number|null } | null,
|
||||||
|
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
|
||||||
|
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
|
||||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
||||||
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
||||||
*/
|
*/
|
||||||
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
||||||
config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
|
config = null, frozenAt = null, pause = null, live = null, spectator = false,
|
||||||
|
spectators = null, myId = null, isHost = false, onRemoveSpectator = null }) {
|
||||||
const phase = pub?.phase;
|
const phase = pub?.phase;
|
||||||
const boss = isBossPhase(phase);
|
const boss = isBossPhase(phase);
|
||||||
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
|
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
|
||||||
@@ -338,7 +380,10 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
|||||||
<div class="gtop__left">
|
<div class="gtop__left">
|
||||||
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
|
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
|
||||||
<div class="gtop__meta">
|
<div class="gtop__meta">
|
||||||
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
|
<div class="gtop__net">
|
||||||
|
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
|
||||||
|
<${SpectatorPill} spectators=${spectators} myId=${myId} isHost=${isHost} onRemove=${onRemoveSpectator} />
|
||||||
|
</div>
|
||||||
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
|
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
|
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
|
||||||
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
|
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
|
||||||
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
|
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
|
||||||
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded).
|
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded). The
|
||||||
|
// lobby and the room open it from a 设置 button next to 玩法说明 (SettingsButton, GitHub #238); the title screen and the
|
||||||
|
// match have their own gear.
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||||
|
import { GIcon } from './gameComponents.js';
|
||||||
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
|
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
|
||||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
|
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
|
||||||
import { audio } from '../audio.js';
|
import { audio } from '../audio.js';
|
||||||
@@ -247,3 +250,16 @@ export function SettingsModal({ open, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<//>`;
|
<//>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title
|
||||||
|
* screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it
|
||||||
|
* (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only.
|
||||||
|
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props
|
||||||
|
*/
|
||||||
|
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
|
||||||
|
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label}<//>
|
||||||
|
${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`;
|
||||||
|
}
|
||||||
+13
-1
@@ -183,6 +183,17 @@ function SoldCard({ item = false }) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slot with no card in it: the one a 调度中心 upgrade has just opened (a `null` slot — server/match/player/economy.js
|
||||||
|
* _openLevelSlots; GitHub #332 / PR #333: the official shop shows the new slot, empty, until the next refresh or round
|
||||||
|
* start fills it). Never 已招募 / SOLD OUT: nothing was bought there.
|
||||||
|
*/
|
||||||
|
function EmptyCard({ item = false }) {
|
||||||
|
// `scard--sold` too: the same inert frame (no hover lift, not a buyable card for every `:not(.scard--sold)` rule); `scard--empty` only restyles it
|
||||||
|
return html`<div class=${cx('scard', 'scard--sold', 'scard--empty', item && 'scard--item')} role="img" aria-label=${t('空栏位:刷新或下回合开始时补满')}
|
||||||
|
title=${t('空栏位:刷新或下回合开始时补满')}></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function LevelCard({ shop, reason, armed = false, onTap }) {
|
function LevelCard({ shop, reason, armed = false, onTap }) {
|
||||||
const lv = shop?.level ?? 1;
|
const lv = shop?.level ?? 1;
|
||||||
const max = lv >= (shop?.maxLevel ?? 6);
|
const max = lv >= (shop?.maxLevel ?? 6);
|
||||||
@@ -356,7 +367,8 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
|||||||
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
|
armed=${armed} onTap=${tapCard} offBonds=${offBonds} />`
|
||||||
: html`<div class="shopbar__cards">
|
: html`<div class="shopbar__cards">
|
||||||
${chessSlots.map(({ s, i }) => {
|
${chessSlots.map(({ s, i }) => {
|
||||||
if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
|
if (!s) return html`<${EmptyCard} key=${`e${i}`} />`;
|
||||||
|
if (s.sold) return html`<${SoldCard} key=${`s${i}`} />`;
|
||||||
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
|
const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS });
|
||||||
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
|
return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds}
|
||||||
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
|
reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`;
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
|||||||
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
|
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
|
||||||
const next = gd.upgradeBase(ps.shop.level) ?? 0;
|
const next = gd.upgradeBase(ps.shop.level) ?? 0;
|
||||||
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
|
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
|
||||||
// the new level's extra slots open at once (item 19 of 2026-10-06); the cards shown before stay in place
|
// the new level's extra slots open at once, empty (GitHub #332 / PR #333); the cards shown before stay in place
|
||||||
const { chess, item } = gd.shopSlots(ps.shop.level);
|
const { chess, item } = gd.shopSlots(ps.shop.level);
|
||||||
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
|
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
|
||||||
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
|
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
|
||||||
|
|||||||
+52
-18
@@ -17,9 +17,11 @@
|
|||||||
// Client-side combat (DESIGN §14) helpers:
|
// Client-side combat (DESIGN §14) helpers:
|
||||||
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
|
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
|
||||||
// under a real scheduler) or at once (verification, virtual time) — and keep a
|
// under a real scheduler) or at once (verification, virtual time) — and keep a
|
||||||
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
|
// progress timeline [[gt, killed, total, resolved]] for the teammates' waiting UI
|
||||||
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
|
// (联防: a 5th element — left = spec.js uniteLeft, the leakers'
|
||||||
// enemies still standing, for their live counter; user playtest #6 item 7)
|
// enemies still standing, for their live counter; user playtest #6 item 7).
|
||||||
|
// `resolved` = the HUD capsule's numerator (Battle.resolved), sampled on the
|
||||||
|
// field clock: a server-run / bot field has no authority reporting b.progress
|
||||||
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
|
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
|
||||||
// (boss fields that share the pool while other fields run on clients); a
|
// (boss fields that share the pool while other fields run on clients); a
|
||||||
// takeover's fast-forward is spread over the pacing intervals
|
// takeover's fast-forward is spread over the pacing intervals
|
||||||
@@ -56,7 +58,7 @@ export function snapFrame(fieldId, snap) {
|
|||||||
|
|
||||||
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
|
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
|
||||||
export function emptyPerPlayer() {
|
export function emptyPerPlayer() {
|
||||||
return { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
|
return { killed: 0, total: 0, resolved: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A finished stand-in for a battle that failed to construct. */
|
/** A finished stand-in for a battle that failed to construct. */
|
||||||
@@ -72,13 +74,13 @@ export class DeadBattle {
|
|||||||
this.errorCount = 1;
|
this.errorCount = 1;
|
||||||
const perPlayer = {};
|
const perPlayer = {};
|
||||||
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
|
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
|
||||||
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
|
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
}
|
}
|
||||||
step() {}
|
step() {}
|
||||||
forceEnd() {}
|
forceEnd() {}
|
||||||
result() { return this._result; }
|
result() { return this._result; }
|
||||||
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0 }; }
|
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0, resolved: 0 }; }
|
||||||
drainEvents() { return []; }
|
drainEvents() { return []; }
|
||||||
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
|
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
|
||||||
on() { return null; }
|
on() { return null; }
|
||||||
@@ -238,7 +240,7 @@ export class FieldRunner {
|
|||||||
}
|
}
|
||||||
const perPlayer = {};
|
const perPlayer = {};
|
||||||
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
|
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
|
||||||
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
|
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, resolved: 0, errors: 1, synthetic: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,11 +261,15 @@ export const CATCHUP_TICKS_PER_INTERVAL = 240;
|
|||||||
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
|
* A timeline sample of a battle: [gt, killed, total, resolved], plus — 联防 — the leakers' enemies still standing at
|
||||||
* (spec.js uniteLeft; omitted when unknown).
|
* index 4 (spec.js uniteLeft; omitted when unknown). `resolved` is the HUD capsule's numerator of that game second
|
||||||
|
* (Battle.resolved: the field's own scheduled enemies knocked out or leaked) — the server-run / bot fields have no
|
||||||
|
* authority that reports b.progress, so the teammates' capsule reads it from here, never from `progress.leaks`.
|
||||||
*/
|
*/
|
||||||
export function timelineSample(b) {
|
export function timelineSample(b) {
|
||||||
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
|
const total = Number(b && b.total) || 0;
|
||||||
|
const resolved = Number.isFinite(b && b.resolved) ? Math.max(0, Math.min(total, b.resolved)) : null;
|
||||||
|
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, total, resolved];
|
||||||
if (b && b.kind === 'unite') {
|
if (b && b.kind === 'unite') {
|
||||||
let left = null;
|
let left = null;
|
||||||
try { left = uniteLeft(b); } catch { left = null; }
|
try { left = uniteLeft(b); } catch { left = null; }
|
||||||
@@ -276,7 +282,7 @@ export function timelineSample(b) {
|
|||||||
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
|
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
|
||||||
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
|
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
|
||||||
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
|
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
|
||||||
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
|
* runs (timelineSample: [gt, killed, total, resolved(, left)] every TIMELINE_EVERY game seconds, then the final state);
|
||||||
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
|
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
|
||||||
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
|
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
|
||||||
*/
|
*/
|
||||||
@@ -345,9 +351,9 @@ export function runHeadless(battle, opts = {}) {
|
|||||||
return job.output();
|
return job.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
|
/** Timeline sample at game time `gt`: [gt, killed, total, resolved(, left)] of the last sample ≤ gt. */
|
||||||
export function timelineAt(timeline, gt) {
|
export function timelineAt(timeline, gt) {
|
||||||
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
|
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0, null];
|
||||||
let lo = 0, hi = timeline.length - 1;
|
let lo = 0, hi = timeline.length - 1;
|
||||||
if (gt >= timeline[hi][0]) return timeline[hi];
|
if (gt >= timeline[hi][0]) return timeline[hi];
|
||||||
while (lo < hi) {
|
while (lo < hi) {
|
||||||
@@ -734,7 +740,9 @@ const sameMods = (a, b) => {
|
|||||||
/**
|
/**
|
||||||
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
|
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
|
||||||
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
|
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
|
||||||
* nobody else; killed ≤ total ≤ bound; each leaked enemy key exists in the spawns (multiset bound; keys content may
|
* nobody else; killed ≤ bound and total ≤ bound (maxTotal: `killed` counts every counted knock-out — runtime splits
|
||||||
|
* and summons included — and may exceed `total`, which counts only the round's own scheduled enemies; PR #157 capsule
|
||||||
|
* semantics); each leaked enemy key exists in the spawns (multiset bound; keys content may
|
||||||
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
|
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
|
||||||
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
|
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
|
||||||
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
|
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
|
||||||
@@ -742,7 +750,12 @@ const sameMods = (a, b) => {
|
|||||||
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
|
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
|
||||||
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
|
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
|
||||||
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
|
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
|
||||||
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
|
* perfect consistent with the counted leaks; unit states only for the player's own units, within range. `resolved` (the
|
||||||
|
* HUD capsule's numerator): the result's own — the FIELD's, what the client's capsule showed — is kept as reported,
|
||||||
|
* clamped to the field's `total`, and is what m.public publishes (never the sum of the players': an enemy that crosses
|
||||||
|
* the midline of a two-half field is billed to one player's `total` and to the other's leak, so their own numbers
|
||||||
|
* clamp it to 0); the players' own (each clamped to their `total`) are the fallback of a result with none; absent when
|
||||||
|
* neither is complete.
|
||||||
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
|
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
|
||||||
*/
|
*/
|
||||||
export function validateClientResult(spec, raw, { gd = null } = {}) {
|
export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||||
@@ -781,7 +794,10 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
|||||||
const p = raw.perPlayer[pid];
|
const p = raw.perPlayer[pid];
|
||||||
if (!p || typeof p !== 'object') return bad('player');
|
if (!p || typeof p !== 'object') return bad('player');
|
||||||
const own = B.players.get(pid);
|
const own = B.players.get(pid);
|
||||||
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > p.total || p.total > B.maxTotal) return bad('counts');
|
// `killed` counts every counted knock-out — a runtime split child / summon too — while `total` counts only the
|
||||||
|
// enemies the round scheduled, so `killed ≤ total` no longer holds (PR #157 capsule semantics): bound it by
|
||||||
|
// maxTotal (the spec's content-spawn allowance) instead, exactly like `total`
|
||||||
|
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > B.maxTotal || p.total > B.maxTotal) return bad('counts');
|
||||||
const leaked = [];
|
const leaked = [];
|
||||||
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
|
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
|
||||||
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
|
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
|
||||||
@@ -820,7 +836,9 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
|||||||
leaked.push(e);
|
leaked.push(e);
|
||||||
}
|
}
|
||||||
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
|
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
|
||||||
if (!bossLike && countedLeaks > p.total + B.spawnCount) return bad('leaks > total');
|
// `total` is the round's own scheduled enemies; the leaks may include content-spawned children (a split / summon),
|
||||||
|
// which the spec bounds by maxTotal (the same allowance `killed` is checked against above)
|
||||||
|
if (!bossLike && countedLeaks > p.total + B.maxTotal) return bad('leaks > total');
|
||||||
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
|
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
|
||||||
const layerGains = {};
|
const layerGains = {};
|
||||||
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
|
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
|
||||||
@@ -868,10 +886,26 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
|||||||
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
|
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
|
||||||
unitsEnd, unitStats,
|
unitsEnd, unitStats,
|
||||||
};
|
};
|
||||||
|
// the HUD capsule's numerator: kept only when the client reported one (an absent value must stay absent — the
|
||||||
|
// teammate UI then falls back to `killed`, never to a fabricated 0); clamped to the denominator
|
||||||
|
if (Number.isInteger(p.resolved) && p.resolved >= 0) perPlayer[pid].resolved = Math.min(p.total, p.resolved);
|
||||||
}
|
}
|
||||||
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
|
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
|
||||||
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
|
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
|
||||||
for (const pid of pids) { result.killed += perPlayer[pid].killed; result.total += perPlayer[pid].total; }
|
let ownResolved = 0, ownKnown = pids.length > 0;
|
||||||
|
for (const pid of pids) {
|
||||||
|
result.killed += perPlayer[pid].killed;
|
||||||
|
result.total += perPlayer[pid].total;
|
||||||
|
if (Number.isInteger(perPlayer[pid].resolved)) ownResolved += perPlayer[pid].resolved; else ownKnown = false;
|
||||||
|
}
|
||||||
|
// The FIELD's capsule numerator (Battle.resolved, what the client's own capsule showed), kept as reported and clamped
|
||||||
|
// to the field's total — NOT the sum of the players' own numbers: an enemy that spawns on one half and leaks on the
|
||||||
|
// other (the 联防 lane, the boss pair's crossing routes) is billed to one player's `total` and to the other's
|
||||||
|
// `leakedInTotal`, so each player's own min(total, …) clamps it away (0 and 0 for an enemy the field resolved). The sum
|
||||||
|
// is only the fallback of a result with no field-level number; none of either stays absent (the teammate UI then
|
||||||
|
// falls back to `killed`, never to a fabricated 0).
|
||||||
|
if (Number.isInteger(raw.resolved) && raw.resolved >= 0) result.resolved = Math.min(result.total, raw.resolved);
|
||||||
|
else if (ownKnown) result.resolved = Math.min(result.total, ownResolved);
|
||||||
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
|
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
|
||||||
const unspawned = [];
|
const unspawned = [];
|
||||||
for (const u of raw.unspawned) {
|
for (const u of raw.unspawned) {
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ export class MatchClientCombat {
|
|||||||
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
|
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
|
||||||
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
|
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part') total += Math.max(1, Math.floor(Number(x.count) || 1));
|
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part' && x.countInTotal !== false) total += Math.max(1, Math.floor(Number(x.count) || 1));
|
||||||
return {
|
return {
|
||||||
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
|
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
|
||||||
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
|
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
|
||||||
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
|
// progress: the field's own numbers as far as they are known before its first b.progress / result (`total` = the
|
||||||
|
// spec's scheduled enemies — the capsule's denominator). `resolved` starts at **null**: it is only adopted from a
|
||||||
|
// real report, so `resolved ?? killed` fallbacks (an unreported field, a synthetic result) keep working — a 0 here
|
||||||
|
// would read as "nothing resolved yet" and could never be told apart from a reported 0.
|
||||||
|
progress: { gt: 0, killed: 0, total, leaks: 0, resolved: null, done: false }, lastProgressAt: this.sched.now(),
|
||||||
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
|
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
|
||||||
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
|
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
|
||||||
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
|
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export class MatchReports {
|
|||||||
// the latest total (spawns never reached before the limit leave it at the end)
|
// the latest total (spawns never reached before the limit leave it at the end)
|
||||||
p.total = Math.min(maxTotal, Math.max(0, msg.total | 0));
|
p.total = Math.min(maxTotal, Math.max(0, msg.total | 0));
|
||||||
p.killed = Math.min(p.total, Math.max(p.killed, msg.killed | 0));
|
p.killed = Math.min(p.total, Math.max(p.killed, msg.killed | 0));
|
||||||
|
// the HUD capsule's numerator as the authority reported it (shared/protocol.js b.progress `resolved`). Only a real
|
||||||
|
// integer is adopted — an absent field leaves the null placeholder alone, so "not reported" and a reported 0 differ
|
||||||
|
// (a `Number(null) === 0` here would defeat every `resolved ?? killed` fallback); a lower value is kept (the final
|
||||||
|
// report drops never-spawned enemies from `total`, and a takeover may report a different number)
|
||||||
|
if (Number.isInteger(msg.resolved)) p.resolved = Math.max(0, Math.min(1e5, msg.resolved));
|
||||||
f.lastProgressAt = this.sched.now();
|
f.lastProgressAt = this.sched.now();
|
||||||
if (f.kind === 'boss' || f.kind === 'hidden') {
|
if (f.kind === 'boss' || f.kind === 'hidden') {
|
||||||
this._creditBoss(f, msg.bossDmg, msg.by);
|
this._creditBoss(f, msg.bossDmg, msg.by);
|
||||||
|
|||||||
@@ -126,8 +126,9 @@ export class MatchUnite {
|
|||||||
let live = null;
|
let live = null;
|
||||||
if (f && f.cc) {
|
if (f && f.cc) {
|
||||||
if (f.mode === 'server' && f.timeline) {
|
if (f.mode === 'server' && f.timeline) {
|
||||||
|
// the sample is [gt, killed, total, resolved(, left)]: a 联防 sample's `left` is its 5th element
|
||||||
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||||
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
|
live = sample && sample[4] && typeof sample[4] === 'object' ? sample[4] : null;
|
||||||
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
||||||
} else if (f && f.battle) {
|
} else if (f && f.battle) {
|
||||||
try { live = uniteLeft(f.battle); } catch { live = null; }
|
try { live = uniteLeft(f.battle); } catch { live = null; }
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import { timelineAt } from '../fields.js';
|
|||||||
import { bossFieldPlacement } from '../finalAssault.js';
|
import { bossFieldPlacement } from '../finalAssault.js';
|
||||||
import { battleProgress } from '../../sim/spec.js';
|
import { battleProgress } from '../../sim/spec.js';
|
||||||
|
|
||||||
|
/** A reported capsule numerator clamped to its denominator, else null (unknown — never a fabricated 0). */
|
||||||
|
const finiteOrNull = (v, cap = Infinity) => {
|
||||||
|
if (v == null) return null; // Number(null) === 0: an unreported value must not read as "0 resolved"
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
|
||||||
|
};
|
||||||
|
|
||||||
export class MatchViews {
|
export class MatchViews {
|
||||||
statusOf(ps) {
|
statusOf(ps) {
|
||||||
if (ps.left) return 'left';
|
if (ps.left) return 'left';
|
||||||
@@ -127,25 +135,45 @@ export class MatchViews {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** m.public.fields[].progress: { killed, total, done } (teammates' waiting UI). */
|
/**
|
||||||
|
* m.public.fields[].progress: { killed, resolved, total, done } (teammates' waiting UI). `resolved` is the HUD
|
||||||
|
* capsule's numerator — the field's own scheduled enemies knocked out or leaked (Battle.resolved / the reported
|
||||||
|
* b.progress `resolved`); it is `null` (never 0) while unknown, so the client's `resolved ?? killed` fallback holds.
|
||||||
|
* `total` is the capsule's denominator: only the enemies the round scheduled (runtime splits / summons — boss summons
|
||||||
|
* included — are in neither part).
|
||||||
|
*/
|
||||||
_fieldProgress(f) {
|
_fieldProgress(f) {
|
||||||
if (!f) return null;
|
if (!f) return null;
|
||||||
if (!f.cc) {
|
if (!f.cc) {
|
||||||
const b = f.battle;
|
const b = f.battle;
|
||||||
if (!b) return null;
|
if (!b) return null;
|
||||||
return { killed: Number(b.killed) || 0, total: Number(b.total) || 0, done: !f.live };
|
const total = Number(b.total) || 0;
|
||||||
|
return { killed: Number(b.killed) || 0, resolved: finiteOrNull(b.resolved, total), total, done: !f.live };
|
||||||
}
|
}
|
||||||
if (f.done && f.result) {
|
if (f.done && f.result) {
|
||||||
let killed = 0, total = 0;
|
let killed = 0, total = 0, own = 0, ownKnown = true;
|
||||||
for (const pp of Object.values(f.result.perPlayer || {})) { killed += Number(pp && pp.killed) || 0; total += Number(pp && pp.total) || 0; }
|
for (const pp of Object.values(f.result.perPlayer || {})) {
|
||||||
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; }
|
killed += Number(pp && pp.killed) || 0;
|
||||||
return { killed, total, done: true };
|
total += Number(pp && pp.total) || 0;
|
||||||
|
if (Number.isFinite(pp && pp.resolved)) own += Number(pp.resolved); else ownKnown = false;
|
||||||
|
}
|
||||||
|
// the FIELD's numerator (the validated client result / Battle.result(): what the capsule showed) — not the sum of the
|
||||||
|
// players' own: an enemy that spawns on one half and leaks on the other (a 联防 lane, the boss pair's crossing routes)
|
||||||
|
// is billed to one player's `total` and to the other's leak, so their own min(total, …) clamp it to 0
|
||||||
|
let resolved = finiteOrNull(f.result.resolved, total);
|
||||||
|
if (resolved == null && ownKnown) resolved = Math.min(total, own);
|
||||||
|
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; resolved = finiteOrNull(f.progress.resolved, total); }
|
||||||
|
return { killed, resolved, total, done: true };
|
||||||
}
|
}
|
||||||
if (f.mode === 'server' && f.timeline) {
|
if (f.mode === 'server' && f.timeline) {
|
||||||
const [, killed, total] = timelineAt(f.timeline, this._fieldElapsed(f));
|
// a server-run / bot field: no authority ever sends a b.progress, so the capsule reads the battle's own counters —
|
||||||
return { killed, total, done: false };
|
// the timeline sample carries `resolved` (Battle.resolved: knocked out + leaked among the field's own enemies),
|
||||||
|
// never the report-driven `progress.leaks`, which would leave such a field at 0 forever
|
||||||
|
const [, killed, total, resolved] = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||||
|
return { killed, resolved: finiteOrNull(resolved, total), total, done: false };
|
||||||
}
|
}
|
||||||
return { killed: f.progress.killed, total: f.progress.total, done: false };
|
const total = f.progress.total;
|
||||||
|
return { killed: f.progress.killed, resolved: finiteOrNull(f.progress.resolved, total), total, done: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,11 +93,14 @@ export class PlayerEconomy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 调度中心's upgrade opens the new level's extra slots at once, each with a new card drawn at the new level; the
|
* The 调度中心's upgrade opens the new level's extra slots at once — EMPTY: no card is drawn into them (a `null` slot).
|
||||||
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the
|
* The next roll (a manual refresh or the round start) fills them with the other unfrozen slots; the cards already shown
|
||||||
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06
|
* stay where they are (chess slots keep their index, the item slot stays after them). Official: the footage of a 1→2
|
||||||
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a
|
* upgrade (bilibili BV1AXwuzdEys 1:39, GitHub #332 / PR #333 by @2321Robin) shows the new slot appear and stay empty, and
|
||||||
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do.
|
* the texts only name the slots — the tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」 —,
|
||||||
|
* never a card that comes with them. 0.2.0 drew a card into each new slot instead (item 19 of the community report of
|
||||||
|
* 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark; its follow-on, that the new card
|
||||||
|
* follows the freeze toggle, was [ASSUMED]); until then the extra slots waited for the next roll as well.
|
||||||
*/
|
*/
|
||||||
_openLevelSlots() {
|
_openLevelSlots() {
|
||||||
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
|
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
|
||||||
@@ -106,9 +109,8 @@ export class PlayerEconomy {
|
|||||||
if (nChess <= layout.chess && nItem <= layout.item) return;
|
if (nChess <= layout.chess && nItem <= layout.item) return;
|
||||||
const chess = old.slice(0, layout.chess);
|
const chess = old.slice(0, layout.chess);
|
||||||
const items = old.slice(layout.chess);
|
const items = old.slice(layout.chess);
|
||||||
const fresh = (s) => { if (s) s.frozen = this.shop.frozen; return s; };
|
while (chess.length < nChess) chess.push(null);
|
||||||
while (chess.length < nChess) chess.push(fresh(this._rollChessSlot()));
|
while (items.length < nItem) items.push(null);
|
||||||
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
|
|
||||||
this.shop.slots = [...chess, ...items];
|
this.shop.slots = [...chess, ...items];
|
||||||
this.shop.layout = { chess: chess.length, item: items.length };
|
this.shop.layout = { chess: chess.length, item: items.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ export class Battle {
|
|||||||
this.killed = 0;
|
this.killed = 0;
|
||||||
this.total = 0;
|
this.total = 0;
|
||||||
this.leakedCount = 0;
|
this.leakedCount = 0;
|
||||||
|
// The HUD capsule's own counters (DESIGN §14, PR #157 semantics): only the enemies the field itself scheduled
|
||||||
|
// (`inTotal`, spawns.js `_queueSpawn` — `total` is their denominator) count here. `killed` / `leakedCount` above keep
|
||||||
|
// the official LP / 完美作战 reading: every `counted` enemy, runtime splits and summons included.
|
||||||
|
this.killedInTotal = 0;
|
||||||
|
this.leakedInTotal = 0;
|
||||||
this.errors = [];
|
this.errors = [];
|
||||||
this.errorCount = 0;
|
this.errorCount = 0;
|
||||||
this._errKeys = new Set();
|
this._errKeys = new Set();
|
||||||
@@ -172,6 +177,16 @@ export class Battle {
|
|||||||
if (typeof opts.setup === 'function') this._safe(() => opts.setup(this), 'opts.setup');
|
if (typeof opts.setup === 'function') this._safe(() => opts.setup(this), 'opts.setup');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies (`inTotal`) that are 已解决 — knocked
|
||||||
|
* down (`killedInTotal`) or leaked (`leakedInTotal`) — never above the denominator `total`. The official reading:
|
||||||
|
* 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3; a leaked split child still costs LP (`leakedCount`) and breaks
|
||||||
|
* 完美作战, but it is no enemy of the stage's own list and moves no capsule number.
|
||||||
|
*/
|
||||||
|
get resolved() {
|
||||||
|
return Math.min(this.total, this.killedInTotal + this.leakedInTotal);
|
||||||
|
}
|
||||||
|
|
||||||
[Symbol.for('nodejs.util.inspect.custom')]() {
|
[Symbol.for('nodejs.util.inspect.custom')]() {
|
||||||
return `Battle<${this.kind} seed=${this.seed} t=${this.time.toFixed(2)} units=${this.units.length}${this.finished ? ' ' + this.reason : ''}>`;
|
return `Battle<${this.kind} seed=${this.seed} t=${this.time.toFixed(2)} units=${this.units.length}${this.finished ? ' ' + this.reason : ''}>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,11 +136,17 @@ export class BattleDeploy {
|
|||||||
this._unblock(unit);
|
this._unblock(unit);
|
||||||
this._enemiesDirty = true;
|
this._enemiesDirty = true;
|
||||||
if (reason === 'killed') {
|
if (reason === 'killed') {
|
||||||
|
const pp = this._pp(unit.ownerId);
|
||||||
if (unit.counted) {
|
if (unit.counted) {
|
||||||
this.killed++;
|
this.killed++;
|
||||||
const pp = this._pp(unit.ownerId);
|
|
||||||
if (pp) pp.killed++;
|
if (pp) pp.killed++;
|
||||||
}
|
}
|
||||||
|
// the HUD capsule's numerator (DESIGN §14): only the enemies the stage itself scheduled (`inTotal`) — a split
|
||||||
|
// child / summon / part knocked down here is no 已解决 of the stage's own list and moves the capsule not at all
|
||||||
|
if (unit.inTotal) {
|
||||||
|
this.killedInTotal++;
|
||||||
|
if (pp) pp.killedInTotal++;
|
||||||
|
}
|
||||||
if (killer) killer.stats.kills++;
|
if (killer) killer.stats.kills++;
|
||||||
if (unit.bounty && unit.bounty.coins > 0) this.addCoins(this._bountyPayee(unit, killer), unit.bounty.coins);
|
if (unit.bounty && unit.bounty.coins > 0) this.addCoins(this._bountyPayee(unit, killer), unit.bounty.coins);
|
||||||
}
|
}
|
||||||
@@ -253,5 +259,12 @@ export class BattleDeploy {
|
|||||||
if (e.counted || e.isBoss) pp.perfect = false;
|
if (e.counted || e.isBoss) pp.perfect = false;
|
||||||
}
|
}
|
||||||
if (e.counted) this.leakedCount++;
|
if (e.counted) this.leakedCount++;
|
||||||
|
// the HUD capsule's leak counter (DESIGN §14): only the stage's own enemies (`inTotal`) — a leaked split child or
|
||||||
|
// summon still costs LP through `counted`/`leakedCount` and still breaks 完美作战, but it is no 漏掉 of the stage's
|
||||||
|
// own list, so the capsule's numerator does not move for it
|
||||||
|
if (e.inTotal) {
|
||||||
|
this.leakedInTotal++;
|
||||||
|
if (pp) pp.leakedInTotal++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,9 @@ export class BattleEvents {
|
|||||||
dp: this.players.length ? Math.floor(this.players[0].dp) : 0,
|
dp: this.players.length ? Math.floor(this.players[0].dp) : 0,
|
||||||
killed: this.killed,
|
killed: this.killed,
|
||||||
total: this.total,
|
total: this.total,
|
||||||
|
// the HUD capsule's numerator (DESIGN §14): the field's own scheduled enemies that are 已解决 (down or leaked).
|
||||||
|
// `killed` above counts every counted knock-out (runtime splits / summons too) and may exceed `total`.
|
||||||
|
resolved: this.resolved,
|
||||||
};
|
};
|
||||||
if (this.players.length > 1) {
|
if (this.players.length > 1) {
|
||||||
snap.dps = {};
|
snap.dps = {};
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class BattleLifecycle {
|
|||||||
this.finished = true;
|
this.finished = true;
|
||||||
this.reason = reason === 'timeout' ? 'timeout' : 'forced';
|
this.reason = reason === 'timeout' ? 'timeout' : 'forced';
|
||||||
this._endReq = null;
|
this._endReq = null;
|
||||||
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, errors: this.errorCount }; }
|
try { this._result = this._buildResult(); } catch { this._result = { time: this.time, reason: this.reason, perPlayer: {}, killed: this.killed, total: this.total, resolved: this.resolved, errors: this.errorCount }; }
|
||||||
this.projectiles.clear();
|
this.projectiles.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +209,9 @@ export class BattleLifecycle {
|
|||||||
for (const ps of this.players) {
|
for (const ps of this.players) {
|
||||||
const pp = this._perPlayer[ps.playerId];
|
const pp = this._perPlayer[ps.playerId];
|
||||||
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
|
pp.perfect = !pp.leaked.some((l) => l.counted !== false);
|
||||||
|
// the HUD capsule's numerator of this field (DESIGN §14): this player's own scheduled enemies resolved — knocked
|
||||||
|
// down or leaked (`counted` keeps the LP / 完美作战 reading, runtime splits and summons included)
|
||||||
|
pp.resolved = Math.min(pp.total, pp.killedInTotal + pp.leakedInTotal);
|
||||||
// the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's
|
// the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's
|
||||||
// SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该
|
// SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该
|
||||||
// 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge).
|
// 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge).
|
||||||
@@ -226,7 +229,7 @@ export class BattleLifecycle {
|
|||||||
}));
|
}));
|
||||||
perPlayer[ps.playerId] = pp;
|
perPlayer[ps.playerId] = pp;
|
||||||
}
|
}
|
||||||
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, errors: this.errorCount };
|
const res = { time: Math.round(this.time * 1000) / 1000, reason: this.reason, perPlayer, killed: this.killed, total: this.total, resolved: this.resolved, errors: this.errorCount };
|
||||||
if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned;
|
if (this.unspawned && this.unspawned.length) res.unspawned = this.unspawned;
|
||||||
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
|
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ export class BattlePlayers {
|
|||||||
};
|
};
|
||||||
this.players.push(ps);
|
this.players.push(ps);
|
||||||
this._perPlayer[ps.playerId] = {
|
this._perPlayer[ps.playerId] = {
|
||||||
killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0,
|
// `killed` / `total` / `leaked` / `perfect` keep the official `counted` reading (DESIGN §5.1); `killedInTotal` /
|
||||||
damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [],
|
// `leakedInTotal` are the HUD capsule's counters of this player's own field (only enemies the round scheduled:
|
||||||
|
// spawns.js `_queueSpawn` / `inTotal`, deploy.js), `resolved` is derived from them at result time.
|
||||||
|
killed: 0, total: 0, leaked: [], perfect: true, killedInTotal: 0, leakedInTotal: 0,
|
||||||
|
layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [],
|
||||||
};
|
};
|
||||||
const late = [];
|
const late = [];
|
||||||
for (const u of p.units ?? []) {
|
for (const u of p.units ?? []) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (counted in `total` up front), routes and the
|
// server/sim/battle/spawns.js — Battle methods: enemies: the spawn queue (the stage's own enemies: `inTotal`, counted in
|
||||||
|
// `total` up front — the HUD capsule's numerator/denominator, deploy.js killedInTotal / leakedInTotal), routes and the
|
||||||
// owner of a tile, spawnEnemy, and the enemies on the field — hidden ones, the shared boss pool sync, the field clamp
|
// owner of a tile, spawnEnemy, and the enemies on the field — hidden ones, the shared boss pool sync, the field clamp
|
||||||
// and the list compaction.
|
// and the list compaction.
|
||||||
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
|
||||||
@@ -31,7 +32,11 @@ export class BattleSpawns {
|
|||||||
ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal,
|
ownerPlayerId: s.ownerPlayerId ?? null, pos: s.pos ?? null, seq: ++this._spawnSeq, countInTotal: s.countInTotal,
|
||||||
};
|
};
|
||||||
p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part');
|
p.counted = p.countInTotal ?? (!(def && def.notCountInTotal) && p.tag !== 'boss' && p.tag !== 'part');
|
||||||
if (precount && p.counted) {
|
// inTotal: the enemies THIS STAGE scheduled that count — the HUD capsule's own set (DESIGN §14 "顶栏胶囊"). A
|
||||||
|
// runtime spawn (a split child, a summon, a part, a form change) is never in it, so the capsule's denominator stays
|
||||||
|
// the stage's own enemies while `counted` (LP, 破坏完美作战 and the kill counters) keeps counting them all.
|
||||||
|
p.inTotal = !!p.counted;
|
||||||
|
if (precount && p.inTotal) {
|
||||||
this.total++;
|
this.total++;
|
||||||
const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start);
|
const owner = p.ownerPlayerId ?? this._ownerForTile(p.pos ?? this._routeFor(p.routeIndex, p.route)?.start);
|
||||||
const pp = this._pp(owner);
|
const pp = this._pp(owner);
|
||||||
@@ -73,7 +78,7 @@ export class BattleSpawns {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag,
|
* Spawn an enemy now. opts: { routeIndex, route, pos:[r,c], mods:{hpMul,atkMul,defMul,resMul,speedMul}, tag,
|
||||||
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal }
|
* sourcePlayerId, ownerPlayerId, bounty:{coins,ownerPlayerId}, countInTotal, inTotal }
|
||||||
*/
|
*/
|
||||||
spawnEnemy(enemyKey, opts = {}) {
|
spawnEnemy(enemyKey, opts = {}) {
|
||||||
if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) {
|
if (this.enemies.length >= MAX_ALIVE_ENEMIES && this.aliveEnemies().length >= MAX_ALIVE_ENEMIES) {
|
||||||
@@ -144,7 +149,12 @@ export class BattleSpawns {
|
|||||||
if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true });
|
if (end) e.route.legs.push({ t: 'move', r: end[0], c: end[1], final: true });
|
||||||
}
|
}
|
||||||
e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part');
|
e.counted = opts.countInTotal ?? (!def.notCountInTotal && e.tag !== 'boss' && e.tag !== 'part');
|
||||||
if (e.counted && !opts._precounted) {
|
// inTotal (the HUD capsule's own counter, DESIGN §14): only an enemy the stage scheduled (`_queueSpawn`, which
|
||||||
|
// pre-counts it) — or, for future content, an explicit `opts.inTotal: true` — and that counts. Everything spawned
|
||||||
|
// while the battle runs (a split child, a summon — bosses included —, a part, a 变身 copy) stays out of the capsule's
|
||||||
|
// numerator and denominator, while `counted` (LP, 破坏完美作战, `killed`) keeps counting it as before.
|
||||||
|
e.inTotal = opts.inTotal === true && e.counted;
|
||||||
|
if (e.inTotal && !opts._precounted) {
|
||||||
this.total++;
|
this.total++;
|
||||||
const pp = this._pp(e.ownerId);
|
const pp = this._pp(e.ownerId);
|
||||||
if (pp) pp.total++;
|
if (pp) pp.total++;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2).
|
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2).
|
||||||
//
|
//
|
||||||
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
|
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved }
|
||||||
// (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left)
|
// (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left)
|
||||||
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
|
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
|
||||||
// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo
|
// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo
|
||||||
|
|||||||
+22
-4
@@ -262,7 +262,8 @@ export function uniteLeft(battle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
|
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, the capsule's
|
||||||
|
* `resolved` (this field's own scheduled enemies knocked out or leaked — Battle.resolved), counted leaks
|
||||||
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
|
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
|
||||||
* still standing).
|
* still standing).
|
||||||
*/
|
*/
|
||||||
@@ -273,10 +274,14 @@ export function battleProgress(battle) {
|
|||||||
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
|
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
|
||||||
const pool = battle && battle.sharedBoss;
|
const pool = battle && battle.sharedBoss;
|
||||||
const gt = Number(battle && battle.time) || 0;
|
const gt = Number(battle && battle.time) || 0;
|
||||||
|
const total = Math.max(0, Math.trunc(Number(battle && battle.total) || 0));
|
||||||
const out = {
|
const out = {
|
||||||
gt: Math.round(gt * 1000) / 1000,
|
gt: Math.round(gt * 1000) / 1000,
|
||||||
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
|
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
|
||||||
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
|
total,
|
||||||
|
// `null` (never 0) when the battle cannot report it: a display replica / a stand-in must not look like "0 resolved"
|
||||||
|
// (a `0` from here would defeat every `resolved ?? killed` fallback of the HUD)
|
||||||
|
resolved: numberOrNull(battle && battle.resolved, total),
|
||||||
leaks,
|
leaks,
|
||||||
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
|
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
|
||||||
done: !!(battle && battle.finished),
|
done: !!(battle && battle.finished),
|
||||||
@@ -288,6 +293,14 @@ export function battleProgress(battle) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A finite, non-negative integer (never above `cap`), else null — a value the HUD may fall back from. */
|
||||||
|
function numberOrNull(v, cap = Infinity) {
|
||||||
|
if (v == null) return null; // Number(null) === 0: a battle without the counter (a stand-in) must not read as "0"
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return Math.max(0, Math.min(cap, Math.trunc(n)));
|
||||||
|
}
|
||||||
|
|
||||||
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
|
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
|
||||||
const modsKey = (m) => (m && typeof m === 'object' ? JSON.stringify(Object.keys(m).filter((k) => m[k] !== undefined).sort().map((k) => [k, m[k]])) : '');
|
const modsKey = (m) => (m && typeof m === 'object' ? JSON.stringify(Object.keys(m).filter((k) => m[k] !== undefined).sort().map((k) => [k, m[k]])) : '');
|
||||||
|
|
||||||
@@ -348,7 +361,9 @@ export function compactResult(res) {
|
|||||||
for (const [k, v] of Object.entries(p.layerGains || {}).slice(0, 40)) if (isKey(k) && fnum(v) > 0) layerGains[k] = Math.min(1e4, fnum(v));
|
for (const [k, v] of Object.entries(p.layerGains || {}).slice(0, 40)) if (isKey(k) && fnum(v) > 0) layerGains[k] = Math.min(1e4, fnum(v));
|
||||||
const total = Math.max(0, Math.trunc(fnum(p.total)));
|
const total = Math.max(0, Math.trunc(fnum(p.total)));
|
||||||
perPlayer[pid] = {
|
perPlayer[pid] = {
|
||||||
killed: Math.min(total, Math.max(0, Math.trunc(fnum(p.killed)))),
|
// `killed` keeps the sim's reading (every counted knock-out, runtime splits / summons included) and is deliberately
|
||||||
|
// NOT clamped to `total`: the denominator counts only the round's own scheduled enemies (Battle.resolved)
|
||||||
|
killed: Math.max(0, Math.trunc(fnum(p.killed))),
|
||||||
total,
|
total,
|
||||||
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => {
|
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => {
|
||||||
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
|
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
|
||||||
@@ -373,6 +388,9 @@ export function compactResult(res) {
|
|||||||
taken: Math.max(0, Math.round(fnum(u.taken))), attacks: Math.max(0, Math.trunc(fnum(u.attacks))),
|
taken: Math.max(0, Math.round(fnum(u.taken))), attacks: Math.max(0, Math.trunc(fnum(u.attacks))),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
// the HUD capsule's numerator (or absent for a result that has none: the teammate UI falls back to `killed`)
|
||||||
|
const resolved = Number.isFinite(p.resolved) ? Math.max(0, Math.min(total, Math.trunc(p.resolved))) : null;
|
||||||
|
if (resolved != null) perPlayer[pid].resolved = resolved;
|
||||||
}
|
}
|
||||||
const out = {
|
const out = {
|
||||||
reason: ['cleared', 'timeout', 'forced'].includes(r.reason) ? r.reason : 'forced',
|
reason: ['cleared', 'timeout', 'forced'].includes(r.reason) ? r.reason : 'forced',
|
||||||
@@ -382,7 +400,7 @@ export function compactResult(res) {
|
|||||||
perPlayer,
|
perPlayer,
|
||||||
errors: Math.max(0, Math.min(1e9, Math.trunc(fnum(r.errors)))),
|
errors: Math.max(0, Math.min(1e9, Math.trunc(fnum(r.errors)))),
|
||||||
};
|
};
|
||||||
if (out.killed > out.total) out.killed = out.total;
|
if (Number.isFinite(r.resolved)) out.resolved = Math.max(0, Math.min(out.total, Math.trunc(r.resolved)));
|
||||||
if (Array.isArray(r.unspawned) && r.unspawned.length) {
|
if (Array.isArray(r.unspawned) && r.unspawned.length) {
|
||||||
out.unspawned = cap(r.unspawned, 400).filter((u) => u && isKey(u.enemyKey)).map((u) => ({
|
out.unspawned = cap(r.unspawned, 400).filter((u) => u && isKey(u.enemyKey)).map((u) => ({
|
||||||
enemyKey: u.enemyKey, sourcePlayerId: keyOr(u.sourcePlayerId), tag: typeof u.tag === 'string' && u.tag.length <= 16 ? u.tag : null,
|
enemyKey: u.enemyKey, sourcePlayerId: keyOr(u.sourcePlayerId), tag: typeof u.tag === 'string' && u.tag.length <= 16 ? u.tag : null,
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ export class Unit {
|
|||||||
this.anim = 0;
|
this.anim = 0;
|
||||||
this.persist = { redeployMul: 1, freeRedeploys: 0 };
|
this.persist = { redeployMul: 1, freeRedeploys: 0 };
|
||||||
this.isBoss = false;
|
this.isBoss = false;
|
||||||
|
// the HUD capsule's own flag (battle/spawns.js: set on the enemies the field itself scheduled, DESIGN §14); declared
|
||||||
|
// here so every unit keeps the same object shape (the hot loops' property reads)
|
||||||
|
this.inTotal = false;
|
||||||
this.bossPool = null;
|
this.bossPool = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -40,7 +40,12 @@ const isUnitEnd = (u) => isPlain(u) && nullable(isUid)(u.uid) && isNum(u.hpPct,
|
|||||||
&& optional(isBool)(u.skillActive) && nullable(isId)(u.defId);
|
&& optional(isBool)(u.skillActive) && nullable(isId)(u.defId);
|
||||||
const isUnitStat = (u) => isPlain(u) && nullable(isUid)(u.uid) && nullable(isId)(u.defId) && optional((v) => isStr(v, 16))(u.kind)
|
const isUnitStat = (u) => isPlain(u) && nullable(isUid)(u.uid) && nullable(isId)(u.defId) && optional((v) => isStr(v, 16))(u.kind)
|
||||||
&& isStat(u.dmg) && isStat(u.kills) && isStat(u.heal) && isStat(u.taken) && isStat(u.attacks);
|
&& isStat(u.dmg) && isStat(u.kills) && isStat(u.heal) && isStat(u.taken) && isStat(u.attacks);
|
||||||
const isPerPlayer = (p) => isPlain(p) && isInt(p.killed, 0, 1e5) && isInt(p.total, 0, 1e5) && p.killed <= p.total
|
const isPerPlayer = (p) => isPlain(p) && isInt(p.killed, 0, 1e5) && isInt(p.total, 0, 1e5)
|
||||||
|
// `resolved` = the HUD capsule's numerator of this player's own field (the round's own scheduled enemies knocked out
|
||||||
|
// or leaked: server/sim/battle/deploy.js killedInTotal / leakedInTotal, Battle.resolved). `killed` counts every counted
|
||||||
|
// knock-out — a runtime split child / summon too — and may therefore exceed `total`, which counts only the round's own
|
||||||
|
// scheduled enemies (server/match/fields.js validateClientResult bounds it against maxTotal instead).
|
||||||
|
&& optional((x) => isInt(x, 0, 1e5))(p.resolved)
|
||||||
&& isList(p.leaked, RESULT_LIMITS.leaked, isLeak) && isBool(p.perfect)
|
&& isList(p.leaked, RESULT_LIMITS.leaked, isLeak) && isBool(p.perfect)
|
||||||
&& isMap(p.layerGains, RESULT_LIMITS.layerGains, isId, (v) => isNum(v, 0, 1e4))
|
&& isMap(p.layerGains, RESULT_LIMITS.layerGains, isId, (v) => isNum(v, 0, 1e4))
|
||||||
&& isStat(p.coins) && isStat(p.damageDealt) && isStat(p.bossDamage) && isStat(p.healingDone) && isStat(p.deaths)
|
&& isStat(p.coins) && isStat(p.damageDealt) && isStat(p.bossDamage) && isStat(p.healingDone) && isStat(p.deaths)
|
||||||
@@ -56,6 +61,7 @@ const isUnspawned = (u) => isPlain(u) && isId(u.enemyKey) && nullable(isId)(u.so
|
|||||||
export function isBattleResult(v) {
|
export function isBattleResult(v) {
|
||||||
return isPlain(v) && ['cleared', 'timeout', 'forced'].includes(v.reason) && isNum(v.time, 0, 1e5)
|
return isPlain(v) && ['cleared', 'timeout', 'forced'].includes(v.reason) && isNum(v.time, 0, 1e5)
|
||||||
&& optional((x) => isInt(x, 0, 1e5))(v.killed) && optional((x) => isInt(x, 0, 1e5))(v.total)
|
&& optional((x) => isInt(x, 0, 1e5))(v.killed) && optional((x) => isInt(x, 0, 1e5))(v.total)
|
||||||
|
&& optional((x) => isInt(x, 0, 1e5))(v.resolved)
|
||||||
&& isMap(v.perPlayer, RESULT_LIMITS.players, isId, isPerPlayer) && Object.keys(v.perPlayer).length > 0
|
&& isMap(v.perPlayer, RESULT_LIMITS.players, isId, isPerPlayer) && Object.keys(v.perPlayer).length > 0
|
||||||
&& (v.unspawned === undefined || isList(v.unspawned, RESULT_LIMITS.unspawned, isUnspawned))
|
&& (v.unspawned === undefined || isList(v.unspawned, RESULT_LIMITS.unspawned, isUnspawned))
|
||||||
&& optional((x) => isInt(x, 0, 1e9))(v.errors) && optional((x) => isNum(x, 0, BIG))(v.bossHpLeft);
|
&& optional((x) => isInt(x, 0, 1e9))(v.errors) && optional((x) => isNum(x, 0, BIG))(v.bossHpLeft);
|
||||||
@@ -385,13 +391,16 @@ export const C2S = {
|
|||||||
|
|
||||||
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle; a 联防 field adds
|
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle; a 联防 field adds
|
||||||
// `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js
|
// `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js
|
||||||
// uniteLeft; user playtest #6 item 7 — the leakers' live counter)
|
// uniteLeft; user playtest #6 item 7 — the leakers' live counter). `resolved` = the HUD capsule's numerator: the
|
||||||
|
// field's own scheduled enemies that were knocked out **or leaked** (official: 漏一个 1/3, 打死一个 2/3,
|
||||||
|
// 打死会分裂的 3/3 — Battle.leakedInTotal; runtime splits / summons stay out of both parts of the capsule).
|
||||||
'b.progress': {
|
'b.progress': {
|
||||||
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5),
|
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5),
|
||||||
|
resolved: (v) => isInt(v, 0, 1e5),
|
||||||
leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG),
|
leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG),
|
||||||
by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool,
|
by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool,
|
||||||
left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)),
|
left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)),
|
||||||
$optional: ['leaks', 'bossDmg', 'by', 'done', 'left'],
|
$optional: ['resolved', 'leaks', 'bossDmg', 'by', 'done', 'left'],
|
||||||
},
|
},
|
||||||
'b.result': { battleId: isId, result: isBattleResult },
|
'b.result': { battleId: isId, result: isBattleResult },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -490,7 +490,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
|
|||||||
assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
|
assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
|
||||||
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
|
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
|
||||||
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
|
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
|
||||||
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
|
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, resolved\?, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
|
||||||
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
|
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
|
||||||
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
|
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
|
||||||
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
|
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ test('matches: the 补位 match\'s human seat fields stand-ins (listed per round
|
|||||||
assert.ok(dg, 'scenario present');
|
assert.ok(dg, 'scenario present');
|
||||||
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
|
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
|
||||||
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
|
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
|
||||||
assert.ok(new Set(fielded).size >= 3, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
|
// (≥ 2 since a level-up leaves its new slot empty — GitHub #332, PR #333 by @2321Robin: this match's shop rolls, and with
|
||||||
|
// them which stand-ins the human seat fields, part from the stream in which the upgrade drew a card into the new slot)
|
||||||
|
assert.ok(new Set(fielded).size >= 2, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
|
||||||
for (const f of fielded) {
|
for (const f of fielded) {
|
||||||
const [id, charId] = f.split('→');
|
const [id, charId] = f.split('→');
|
||||||
const c = CHESS[`chess_char_${id}`];
|
const c = CHESS[`chess_char_${id}`];
|
||||||
|
|||||||
@@ -729,12 +729,12 @@
|
|||||||
"ticks": 1371,
|
"ticks": 1371,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 53,
|
"killed": 53,
|
||||||
"total": 53,
|
"total": 16,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 18,
|
"rngDraws": 18,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":53,"total":53,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":30262,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
"players": {"p1":{"killed":53,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":30262,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_3_12_a",2,null,2334,1,7,0,0,0,0,0,0],
|
["p1",1,"op","chess_char_3_12_a",2,null,2334,1,7,0,0,0,0,0,0],
|
||||||
["p1",2,"op","chess_char_3_16_a",1,null,2173,1,8,0,0,0,0,0,0],
|
["p1",2,"op","chess_char_3_16_a",1,null,2173,1,8,0,0,0,0,0,0],
|
||||||
@@ -752,7 +752,7 @@
|
|||||||
"hooks": [1,64,1371,110,110,180,180,0,53,53,13,10,0,7978,38,0,53,0,1,53,0,0,0],
|
"hooks": [1,64,1371,110,110,180,180,0,53,53,13,10,0,7978,38,0,53,0,1,53,0,0,0],
|
||||||
"events": {"atk":148,"deploy":9,"die":53,"dmg":180,"engage":7,"fx":25,"skill":23,"spawn":64,"status":59},
|
"events": {"atk":148,"deploy":9,"die":53,"dmg":180,"engage":7,"fx":25,"skill":23,"spawn":64,"status":59},
|
||||||
"fx": {"copy":1,"dp":1,"sleep":3,"strike":2,"summon":16,"volley":2},
|
"fx": {"copy":1,"dp":1,"sleep":3,"strike":2,"summon":16,"volley":2},
|
||||||
"snaps": "e32ad9c0 c651ddcb ddac8e0f ee7dddc6 74ae6f3c"
|
"snaps": "5ea42d46 9b9df7e7 90ea3365 87d00bc2 cdcf7237"
|
||||||
},
|
},
|
||||||
"bond-maniShip-high": {
|
"bond-maniShip-high": {
|
||||||
"about": "调和 high: tier 1 layers 999; chess_char_5_19_b chess_char_5_17_b chess_char_6_11_b chess_char_1_03_a chess_char_2_04_a chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b token_10030_mlyss_wtrman",
|
"about": "调和 high: tier 1 layers 999; chess_char_5_19_b chess_char_5_17_b chess_char_6_11_b chess_char_1_03_a chess_char_2_04_a chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b token_10030_mlyss_wtrman",
|
||||||
@@ -1314,12 +1314,12 @@
|
|||||||
"ticks": 2730,
|
"ticks": 2730,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 66,
|
"killed": 66,
|
||||||
"total": 66,
|
"total": 21,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 48,
|
"rngDraws": 48,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":66,"total":66,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":155889,"bossDmg":0,"heal":2860,"deaths":3,"layerGains":{}}},
|
"players": {"p1":{"killed":66,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":155889,"bossDmg":0,"heal":2860,"deaths":3,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,6,15,28905,0,0,35,2],
|
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,6,15,28905,0,0,35,2],
|
||||||
["p1",2,"op","chess_char_1_08_b",1,"uniequip_002_texas",0,0,7,4,6105,3310,62,24,3],
|
["p1",2,"op","chess_char_1_08_b",1,"uniequip_002_texas",0,0,7,4,6105,3310,62,24,3],
|
||||||
@@ -1344,7 +1344,7 @@
|
|||||||
"hooks": [1,79,2730,216,216,542,541,519,69,69,33,31,0,9330,93,15,66,0,1,79,0,0,1],
|
"hooks": [1,79,2730,216,216,542,541,519,69,69,33,31,0,9330,93,15,66,0,1,79,0,0,1],
|
||||||
"events": {"atk":313,"deploy":10,"die":69,"dmg":541,"engage":8,"fx":142,"heal":12,"skill":64,"spawn":77,"status":201},
|
"events": {"atk":313,"deploy":10,"die":69,"dmg":541,"engage":8,"fx":142,"heal":12,"skill":64,"spawn":77,"status":201},
|
||||||
"fx": {"aoe":11,"crit":6,"dodge":1,"dp":3,"sp":3,"substitute":2,"summon":21,"swap":1,"weightless":3,"zone":91},
|
"fx": {"aoe":11,"crit":6,"dodge":1,"dp":3,"sp":3,"substitute":2,"summon":21,"swap":1,"weightless":3,"zone":91},
|
||||||
"snaps": "0d5fce4b 008db012 69aeb33a 4ce7c8d2 b1642f13 224f1fa4 94c69830 28a9268a 8c7e5ae4 7bef3667"
|
"snaps": "afb0ceff f21761d4 d7ea90d0 794e64bc 49e2ff22 cedf8fb7 032b46b6 d8e9a02c bff04f67 e4b0ff92"
|
||||||
},
|
},
|
||||||
"bond-steadShip-low": {
|
"bond-steadShip-low": {
|
||||||
"about": "坚守 low: tier 1 layers 1; chess_char_1_02_a chess_char_1_10_a chess_char_4_11_a chess_char_4_04_a chess_char_4_07_a chess_char_4_09_a chess_char_4_06_a chess_char_4_10_a token_10041_cathy_catsld token_10041_cathy_catsld",
|
"about": "坚守 low: tier 1 layers 1; chess_char_1_02_a chess_char_1_10_a chess_char_4_11_a chess_char_4_04_a chess_char_4_07_a chess_char_4_09_a chess_char_4_06_a chess_char_4_10_a token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||||
|
|||||||
+33
-33
@@ -1443,12 +1443,12 @@
|
|||||||
"ticks": 6115,
|
"ticks": 6115,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 106,
|
"killed": 106,
|
||||||
"total": 223,
|
"total": 99,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 526,
|
"rngDraws": 526,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":106,"total":223,"perfect":false,"leaks":117,"leaksUncounted":0,"coins":0,"dmg":230330,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
|
"players": {"p1":{"killed":106,"total":99,"perfect":false,"leaks":117,"leaksUncounted":0,"coins":0,"dmg":230330,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_chyue",2648.69,1,218,9,6652,5406,0,51,10],
|
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_chyue",2648.69,1,218,9,6652,5406,0,51,10],
|
||||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_chyue",0,0,208,7,11931,8055,0,43,5],
|
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_chyue",0,0,208,7,11931,8055,0,43,5],
|
||||||
@@ -1477,7 +1477,7 @@
|
|||||||
"hooks": [1,257,6115,815,815,888,881,0,132,249,94,94,0,9703,3,76,223,117,1,132,0,0,7],
|
"hooks": [1,257,6115,815,815,888,881,0,132,249,94,94,0,9703,3,76,223,117,1,132,0,0,7],
|
||||||
"events": {"atk":815,"deploy":31,"die":132,"dmg":881,"engage":12,"fx":89,"leak":117,"skill":188,"spawn":238,"status":24},
|
"events": {"atk":815,"deploy":31,"die":132,"dmg":881,"engage":12,"fx":89,"leak":117,"skill":188,"spawn":238,"status":24},
|
||||||
"fx": {"aoe":33,"buff":1,"dodge":7,"summon":48},
|
"fx": {"aoe":33,"buff":1,"dodge":7,"summon":48},
|
||||||
"snaps": "3bbe012e d31f4ea8 cb8f67bb 242b9a30 f3ce30d3 e9830293 cf0729b3 988d2c83 02f42254 4f557eea 8e95bd4c 83c92c27 cefc8d7b 213319ae 9f9a26fc f4d90af3 71c1e322 dc08f466 512b9fd0 d6074647 bfccfff2"
|
"snaps": "1cb8ac18 8da946b7 790e7a4a f39b06ca 2e66dea7 fc31cf4a 3bb31ee9 5259691f e5517687 117f3c7b f499ef53 72bad6b7 d2c5d36a 78b1823c a2ed3107 318dd5e1 39bf29d0 4cdd1f36 8280f0cc 423d96fb b1d5dadb"
|
||||||
},
|
},
|
||||||
"diy-037": {
|
"diy-037": {
|
||||||
"about": "5_diy1_a=huang/S1 5_diy2_a=huang/S2 5_diy1_a=huang/S3 5_diy2_b=huang/S1 5_diy1_b=huang/S2 5_diy2_b=huang/S3 6_diy1_b=chyue/S1/002_chyue 6_diy2_b=chyue/S2/002_chyue 6_diy1_b=chyue/S3/002_chyue 6_diy2_b=chyue/S1/003_chyue 6_diy1_b=chyue/S2/003_chyue 6_diy2_b=chyue/S3/003_chyue",
|
"about": "5_diy1_a=huang/S1 5_diy2_a=huang/S2 5_diy1_a=huang/S3 5_diy2_b=huang/S1 5_diy1_b=huang/S2 5_diy2_b=huang/S3 6_diy1_b=chyue/S1/002_chyue 6_diy2_b=chyue/S2/002_chyue 6_diy1_b=chyue/S3/002_chyue 6_diy2_b=chyue/S1/003_chyue 6_diy1_b=chyue/S2/003_chyue 6_diy2_b=chyue/S3/003_chyue",
|
||||||
@@ -1612,12 +1612,12 @@
|
|||||||
"ticks": 6037,
|
"ticks": 6037,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 15,
|
"killed": 15,
|
||||||
"total": 130,
|
"total": 99,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 615,
|
"rngDraws": 615,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":15,"total":130,"perfect":false,"leaks":115,"leaksUncounted":0,"coins":0,"dmg":611305,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}},
|
"players": {"p1":{"killed":15,"total":99,"perfect":false,"leaks":115,"leaksUncounted":0,"coins":0,"dmg":611305,"bossDmg":0,"heal":0,"deaths":4,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shwaz",1815,1,5,6,30948,0,0,44,8],
|
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shwaz",1815,1,5,6,30948,0,0,44,8],
|
||||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shwaz",1815,1,7,3,48356,0,0,42,2],
|
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shwaz",1815,1,7,3,48356,0,0,42,2],
|
||||||
@@ -1644,7 +1644,7 @@
|
|||||||
"hooks": [1,146,6037,633,633,624,624,0,19,134,66,66,0,21155,181,8,130,115,1,19,0,0,0],
|
"hooks": [1,146,6037,633,633,624,624,0,19,134,66,66,0,21155,181,8,130,115,1,19,0,0,0],
|
||||||
"events": {"atk":633,"deploy":14,"die":19,"dmg":624,"engage":12,"fx":201,"leak":115,"skill":132,"spawn":144,"status":127},
|
"events": {"atk":633,"deploy":14,"die":19,"dmg":624,"engage":12,"fx":201,"leak":115,"skill":132,"spawn":144,"status":127},
|
||||||
"fx": {"crit":189,"summon":12},
|
"fx": {"crit":189,"summon":12},
|
||||||
"snaps": "38c74c5d e93e1fbc 17213ca8 9e5dbf4f aeb96f48 96b6b90b 4ba7be37 44efb9e4 36e8665c 4b6a1bb6 b75f695f 8f4aed34 d37bd760 f91ca2dd 2be01f90 6af5eeab addcfefb d9063e6f e5425fc1 63b87c62 54fc399d"
|
"snaps": "9065d270 13f6ff85 b2a5c04c b102dd18 6e22ab90 6a03313c 44e746bf 098c8b1a f9ccc1cb c41a9dc4 adad77f1 fd868c79 ca018d6f 5405ea3e 17fad043 58ce5efb f52ab9b6 cdb17a71 9e5eb1f7 9b80db2e e8b19910"
|
||||||
},
|
},
|
||||||
"diy-041": {
|
"diy-041": {
|
||||||
"about": "6_diy1_b=shwaz/S1/002_shwaz 6_diy2_b=shwaz/S2/002_shwaz 6_diy1_b=shwaz/S3/002_shwaz 6_diy2_b=shwaz/S1/003_shwaz 6_diy1_b=shwaz/S2/003_shwaz 6_diy2_b=shwaz/S3/003_shwaz 5_diy1_a=bgsnow/S1 5_diy2_a=bgsnow/S2 5_diy1_a=bgsnow/S3 5_diy2_b=bgsnow/S1 5_diy1_b=bgsnow/S2 5_diy2_b=bgsnow/S3",
|
"about": "6_diy1_b=shwaz/S1/002_shwaz 6_diy2_b=shwaz/S2/002_shwaz 6_diy1_b=shwaz/S3/002_shwaz 6_diy2_b=shwaz/S1/003_shwaz 6_diy1_b=shwaz/S2/003_shwaz 6_diy2_b=shwaz/S3/003_shwaz 5_diy1_a=bgsnow/S1 5_diy2_a=bgsnow/S2 5_diy1_a=bgsnow/S3 5_diy2_b=bgsnow/S1 5_diy1_b=bgsnow/S2 5_diy2_b=bgsnow/S3",
|
||||||
@@ -2269,12 +2269,12 @@
|
|||||||
"ticks": 6837,
|
"ticks": 6837,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 43,
|
"killed": 43,
|
||||||
"total": 205,
|
"total": 99,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":43,"total":205,"perfect":false,"leaks":162,"leaksUncounted":0,"coins":0,"dmg":375046,"bossDmg":0,"heal":2958,"deaths":16,"layerGains":{}}},
|
"players": {"p1":{"killed":43,"total":99,"perfect":false,"leaks":162,"leaksUncounted":0,"coins":0,"dmg":375046,"bossDmg":0,"heal":2958,"deaths":16,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,5,1,5895,2960,0,8,2],
|
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,5,1,5895,2960,0,8,2],
|
||||||
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,9,0,5444,2960,0,4,1],
|
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,9,0,5444,2960,0,4,1],
|
||||||
@@ -2302,7 +2302,7 @@
|
|||||||
"hooks": [1,227,6837,648,648,742,742,677,59,221,13,15,0,10521,812,69,205,162,1,59,0,0,0],
|
"hooks": [1,227,6837,648,648,742,742,677,59,221,13,15,0,10521,812,69,205,162,1,59,0,0,0],
|
||||||
"events": {"atk":648,"deploy":19,"die":59,"dmg":688,"engage":12,"fx":121,"leak":162,"skill":33,"spawn":220,"status":42},
|
"events": {"atk":648,"deploy":19,"die":59,"dmg":688,"engage":12,"fx":121,"leak":162,"skill":33,"spawn":220,"status":42},
|
||||||
"fx": {"aoe":2,"crit":2,"shield":14,"summon":41,"thorns":62},
|
"fx": {"aoe":2,"crit":2,"shield":14,"summon":41,"thorns":62},
|
||||||
"snaps": "e7738144 fa178bf1 4f3d484b b5d03ce8 afe1d843 99e0ea61 70f20deb b5ca27cc dc0aba99 af1b025f cfa5c424 f4e158c7 350c8cd1 69bf60f5 90608560 b84e08e9 2fd4f2c5 a130198a 00871290 afb866ce cc73d72e 8837eef9 5010bcac"
|
"snaps": "e7738144 151f13c6 b636a6a5 d0703ed4 852ec2b3 6586b7a9 ece72287 c18ee8a6 f5803b7b d36bafc4 087320ac ed30204b 60b9e874 e2667db9 b7531c0a 91b4ff9b cb45d547 3210802b 095c2645 37708498 6ec41b79 fa8c03c9 695daa3e"
|
||||||
},
|
},
|
||||||
"diy-057": {
|
"diy-057": {
|
||||||
"about": "5_diy1_b=judge/S1/002_judge 5_diy2_b=judge/S2/002_judge 5_diy1_b=judge/S3/002_judge 5_diy2_b=judge/S1/003_judge 5_diy1_b=judge/S2/003_judge 5_diy2_b=judge/S3/003_judge 6_diy1_a=judge/S1 6_diy2_a=judge/S2 6_diy1_a=judge/S3 6_diy2_b=judge/S1 6_diy1_b=judge/S2 6_diy2_b=judge/S3",
|
"about": "5_diy1_b=judge/S1/002_judge 5_diy2_b=judge/S2/002_judge 5_diy1_b=judge/S3/002_judge 5_diy2_b=judge/S1/003_judge 5_diy1_b=judge/S2/003_judge 5_diy2_b=judge/S3/003_judge 6_diy1_a=judge/S1 6_diy2_a=judge/S2 6_diy1_a=judge/S3 6_diy2_b=judge/S1 6_diy1_b=judge/S2 6_diy2_b=judge/S3",
|
||||||
@@ -2518,12 +2518,12 @@
|
|||||||
"ticks": 5825,
|
"ticks": 5825,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 17,
|
"killed": 17,
|
||||||
"total": 117,
|
"total": 75,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":17,"total":117,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":260434,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}},
|
"players": {"p1":{"killed":17,"total":75,"perfect":false,"leaks":100,"leaksUncounted":0,"coins":0,"dmg":260434,"bossDmg":0,"heal":0,"deaths":21,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,103,2,8464,5596,0,19,4],
|
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,103,2,8464,5596,0,19,4],
|
||||||
["p1",2,"op","chess_char_5_diy2_a",1,null,2798,1,142,0,8787,5596,0,20,0],
|
["p1",2,"op","chess_char_5_diy2_a",1,null,2798,1,142,0,8787,5596,0,20,0],
|
||||||
@@ -2550,7 +2550,7 @@
|
|||||||
"hooks": [1,142,5825,388,388,424,424,0,38,138,21,28,0,5293,0,35,117,100,1,42,0,0,0],
|
"hooks": [1,142,5825,388,388,424,424,0,38,138,21,28,0,5293,0,35,117,100,1,42,0,0,0],
|
||||||
"events": {"atk":388,"deploy":23,"die":38,"dmg":424,"engage":12,"fx":18,"leak":100,"skill":42,"spawn":131,"status":74},
|
"events": {"atk":388,"deploy":23,"die":38,"dmg":424,"engage":12,"fx":18,"leak":100,"skill":42,"spawn":131,"status":74},
|
||||||
"fx": {"revive":4,"summon":14},
|
"fx": {"revive":4,"summon":14},
|
||||||
"snaps": "638e6eb2 31544512 a15b078a 17800483 1b7dabbb ce2f306c 19062081 3a041721 39afa7a1 006a4df8 eae507b7 1fba63a7 bf273699 69d9619c 665866c4 88e8e4db 455dea8e 6e7ea6b6 95f2c90c 9a23a08a"
|
"snaps": "638e6eb2 80afea17 3d1f4331 fb3d8acf 99046faa 8da15fd7 000f36ff b76690fc 2ea4822f 617fe41d ddad08cd 9389f818 e1357674 c10e8abd b4f4dd6b d6d77cf4 1a30174a 0af2ac29 d34b557b 112056b4"
|
||||||
},
|
},
|
||||||
"diy-063": {
|
"diy-063": {
|
||||||
"about": "6_diy1_a=lessng/S1 6_diy2_a=lessng/S2 6_diy1_a=lessng/S3 6_diy2_b=lessng/S1 6_diy1_b=lessng/S2 6_diy2_b=lessng/S3 6_diy1_b=lessng/S1/002_lessng 6_diy2_b=lessng/S2/002_lessng 6_diy1_b=lessng/S3/002_lessng 6_diy2_b=lessng/S1/003_lessng 6_diy1_b=lessng/S2/003_lessng 6_diy2_b=lessng/S3/003_lessng",
|
"about": "6_diy1_a=lessng/S1 6_diy2_a=lessng/S2 6_diy1_a=lessng/S3 6_diy2_b=lessng/S1 6_diy1_b=lessng/S2 6_diy2_b=lessng/S3 6_diy1_b=lessng/S1/002_lessng 6_diy2_b=lessng/S2/002_lessng 6_diy1_b=lessng/S3/002_lessng 6_diy2_b=lessng/S1/003_lessng 6_diy1_b=lessng/S2/003_lessng 6_diy2_b=lessng/S3/003_lessng",
|
||||||
@@ -2911,12 +2911,12 @@
|
|||||||
"ticks": 6151,
|
"ticks": 6151,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 32,
|
"killed": 32,
|
||||||
"total": 126,
|
"total": 75,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":32,"total":126,"perfect":false,"leaks":94,"leaksUncounted":0,"coins":0,"dmg":794494,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
|
"players": {"p1":{"killed":32,"total":75,"perfect":false,"leaks":94,"leaksUncounted":0,"coins":0,"dmg":794494,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_phenxi",1,1,4,2,114557,1795,0,73,3],
|
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_phenxi",1,1,4,2,114557,1795,0,73,3],
|
||||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_phenxi",1,1,7,3,270924,1795,0,77,7],
|
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_phenxi",1,1,7,3,270924,1795,0,77,7],
|
||||||
@@ -2941,7 +2941,7 @@
|
|||||||
"hooks": [1,145,6151,358,351,1134,5570,0,47,141,14,13,0,257,0,17,126,94,1,48,0,0,0],
|
"hooks": [1,145,6151,358,351,1134,5570,0,47,141,14,13,0,257,0,17,126,94,1,48,0,0,0],
|
||||||
"events": {"atk":351,"deploy":19,"die":47,"dmg":1120,"engage":12,"fx":167,"leak":94,"skill":27,"spawn":138,"status":40},
|
"events": {"atk":351,"deploy":19,"die":47,"dmg":1120,"engage":12,"fx":167,"leak":94,"skill":27,"spawn":138,"status":40},
|
||||||
"fx": {"aoe":104,"ember":1,"revive":1,"shieldBreak":1,"summon":17,"telegraph":1,"zone":42},
|
"fx": {"aoe":104,"ember":1,"revive":1,"shieldBreak":1,"summon":17,"telegraph":1,"zone":42},
|
||||||
"snaps": "5e309964 e2b294a4 336cbe3c 6268007a 4bb2b146 9232c146 140b6fe6 cdbce0d8 5c252b94 99c7cbee 5d2d6af5 73dc4682 51ea8af4 342978eb 9036bbd3 105a2926 5cfb9b97 74a9fa53 7bdb61aa 3ec7691c 4a72e70c"
|
"snaps": "cc2cf866 7add2346 2b333f0d 914dde3b 59a20841 00c1e926 2f835686 3d9bb179 c198b367 c151a38c aef71635 1771e992 1ff3d4e1 fdd4c4b8 55318fba d036a5b3 35fe084a ec50b0d0 a981982c aec453e7 db864bb0"
|
||||||
},
|
},
|
||||||
"diy-069": {
|
"diy-069": {
|
||||||
"about": "6_diy1_b=phenxi/S1 6_diy2_b=phenxi/S2 6_diy1_b=phenxi/S3 6_diy2_b=phenxi/S1/002_phenxi 6_diy1_b=phenxi/S2/002_phenxi 6_diy2_b=phenxi/S3/002_phenxi 6_diy1_b=phenxi/S1/003_phenxi 6_diy2_b=phenxi/S2/003_phenxi 6_diy1_b=phenxi/S3/003_phenxi 6_diy2_b=phenxi/S1/004_phenxi 6_diy1_b=phenxi/S2/004_phenxi 6_diy2_b=phenxi/S3/004_phenxi",
|
"about": "6_diy1_b=phenxi/S1 6_diy2_b=phenxi/S2 6_diy1_b=phenxi/S3 6_diy2_b=phenxi/S1/002_phenxi 6_diy1_b=phenxi/S2/002_phenxi 6_diy2_b=phenxi/S3/002_phenxi 6_diy1_b=phenxi/S1/003_phenxi 6_diy2_b=phenxi/S2/003_phenxi 6_diy1_b=phenxi/S3/003_phenxi 6_diy2_b=phenxi/S1/004_phenxi 6_diy1_b=phenxi/S2/004_phenxi 6_diy2_b=phenxi/S3/004_phenxi",
|
||||||
@@ -3128,12 +3128,12 @@
|
|||||||
"ticks": 6098,
|
"ticks": 6098,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 67,
|
"killed": 67,
|
||||||
"total": 156,
|
"total": 93,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 138,
|
"rngDraws": 138,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":67,"total":156,"perfect":false,"leaks":89,"leaksUncounted":0,"coins":0,"dmg":877531,"bossDmg":0,"heal":8248,"deaths":18,"layerGains":{}}},
|
"players": {"p1":{"killed":67,"total":93,"perfect":false,"leaks":89,"leaksUncounted":0,"coins":0,"dmg":877531,"bossDmg":0,"heal":8248,"deaths":18,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_thorns",0,0,131,7,17174,5374,0,31,1],
|
["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_thorns",0,0,131,7,17174,5374,0,31,1],
|
||||||
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_thorns",0,0,188,0,11587,8648,587,13,1],
|
["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_thorns",0,0,188,0,11587,8648,587,13,1],
|
||||||
@@ -3160,7 +3160,7 @@
|
|||||||
"hooks": [1,188,6098,435,435,1218,1333,444,85,174,21,21,0,18383,8,44,156,89,1,91,0,0,4],
|
"hooks": [1,188,6098,435,435,1218,1333,444,85,174,21,21,0,18383,8,44,156,89,1,91,0,0,4],
|
||||||
"events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":1558,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9},
|
"events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":1558,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9},
|
||||||
"fx": {"blink":7,"chen3Wave":1050,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4},
|
"fx": {"blink":7,"chen3Wave":1050,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4},
|
||||||
"snaps": "f1faec58 cbf72c28 58e3f561 b8182f46 b1f5b0f5 1de38a22 bc7c2a2b f233c949 01d56108 560ee3fc 8ec7a4b9 b95b08ce 1943beb7 c593b60a e179c3a0 4a2ad117 171d3fbd 1e1c4411 efd84577 32339940 33fdecb2"
|
"snaps": "f1faec58 0d0a446f 117456bd e2110e41 645daba7 d3bdb7ad a53991f8 c5889bd7 5cb419cb 56b004a0 ec27a876 4404a392 f7f12c61 63afbbe1 a14b5e85 f3f42e12 efdf3148 2b91f091 b4e7c871 d82451f0 ba0e0a67"
|
||||||
},
|
},
|
||||||
"diy-074": {
|
"diy-074": {
|
||||||
"about": "5_diy1_b=chen3/S1/002_chen3 5_diy2_b=chen3/S2/002_chen3 5_diy1_b=chen3/S3/002_chen3 6_diy1_a=chen3/S1 6_diy2_a=chen3/S2 6_diy1_a=chen3/S3 6_diy2_b=chen3/S1 6_diy1_b=chen3/S2 6_diy2_b=chen3/S3 6_diy1_b=chen3/S1/002_chen3 6_diy2_b=chen3/S2/002_chen3 6_diy1_b=chen3/S3/002_chen3",
|
"about": "5_diy1_b=chen3/S1/002_chen3 5_diy2_b=chen3/S2/002_chen3 5_diy1_b=chen3/S3/002_chen3 6_diy1_a=chen3/S1 6_diy2_a=chen3/S2 6_diy1_a=chen3/S3 6_diy2_b=chen3/S1 6_diy1_b=chen3/S2 6_diy2_b=chen3/S3 6_diy1_b=chen3/S1/002_chen3 6_diy2_b=chen3/S2/002_chen3 6_diy1_b=chen3/S3/002_chen3",
|
||||||
@@ -3542,12 +3542,12 @@
|
|||||||
"ticks": 5842,
|
"ticks": 5842,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 255,
|
"killed": 255,
|
||||||
"total": 255,
|
"total": 93,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 244,
|
"rngDraws": 244,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":255,"total":255,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":623421,"bossDmg":0,"heal":0,"deaths":2,"layerGains":{}}},
|
"players": {"p1":{"killed":255,"total":93,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":623421,"bossDmg":0,"heal":0,"deaths":2,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_6_diy1_a",0,null,1875,1,1,0,0,0,0,0,0],
|
["p1",1,"op","chess_char_6_diy1_a",0,null,1875,1,1,0,0,0,0,0,0],
|
||||||
["p1",2,"op","chess_char_6_diy2_a",1,null,1875,1,3,0,0,0,0,0,0],
|
["p1",2,"op","chess_char_6_diy2_a",1,null,1875,1,3,0,0,0,0,0,0],
|
||||||
@@ -3572,7 +3572,7 @@
|
|||||||
"hooks": [1,269,5842,481,481,2496,1707,0,257,257,24,20,363,19695,0,18,255,0,1,297,0,0,0],
|
"hooks": [1,269,5842,481,481,2496,1707,0,257,257,24,20,363,19695,0,18,255,0,1,297,0,0,0],
|
||||||
"events": {"atk":1996,"deploy":14,"die":257,"dmg":1707,"engage":8,"fx":357,"skill":44,"spawn":267,"status":41},
|
"events": {"atk":1996,"deploy":14,"die":257,"dmg":1707,"engage":8,"fx":357,"skill":44,"spawn":267,"status":41},
|
||||||
"fx": {"ember":40,"reload":52,"revive":1,"shieldBreak":153,"summon":54,"telegraph":40,"zone":17},
|
"fx": {"ember":40,"reload":52,"revive":1,"shieldBreak":153,"summon":54,"telegraph":40,"zone":17},
|
||||||
"snaps": "30d0dbf8 070d8377 ba316257 264e2c33 72bddae0 d2e7b88a d1fbfd87 0c7c2947 f981c30d 910370b7 10f3a8d7 d122bb52 77e8a4e0 03c43958 9b1b9bcb d0c54363 314530e3 144be113 ea6b1e0b f121eccd"
|
"snaps": "8a9af3b0 2299cb56 47ff12cb ceb52d71 7b0c947c 1dda5aa2 744bfdf3 17a9ef97 281f57a7 831dce1d e2331e7b 4dd31465 c27b30a6 be9f3593 fa841f00 7139d4df d42f76b9 c09066c0 f93531f5 db0cca6a"
|
||||||
},
|
},
|
||||||
"diy-084": {
|
"diy-084": {
|
||||||
"about": "5_diy1_a=ray/S1 5_diy2_a=ray/S2 5_diy1_a=ray/S3 5_diy2_b=ray/S1 5_diy1_b=ray/S2 5_diy2_b=ray/S3 5_diy1_b=ray/S1/002_ray 5_diy2_b=ray/S2/002_ray 5_diy1_b=ray/S3/002_ray 5_diy2_b=ray/S1/003_ray 5_diy1_b=ray/S2/003_ray 5_diy2_b=ray/S3/003_ray",
|
"about": "5_diy1_a=ray/S1 5_diy2_a=ray/S2 5_diy1_a=ray/S3 5_diy2_b=ray/S1 5_diy1_b=ray/S2 5_diy2_b=ray/S3 5_diy1_b=ray/S1/002_ray 5_diy2_b=ray/S2/002_ray 5_diy1_b=ray/S3/002_ray 5_diy2_b=ray/S1/003_ray 5_diy1_b=ray/S2/003_ray 5_diy2_b=ray/S3/003_ray",
|
||||||
@@ -3872,12 +3872,12 @@
|
|||||||
"ticks": 6561,
|
"ticks": 6561,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 280,
|
"killed": 280,
|
||||||
"total": 345,
|
"total": 105,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":280,"total":345,"perfect":false,"leaks":65,"leaksUncounted":0,"coins":0,"dmg":905052,"bossDmg":0,"heal":61143,"deaths":0,"layerGains":{}}},
|
"players": {"p1":{"killed":280,"total":105,"perfect":false,"leaks":65,"leaksUncounted":0,"coins":0,"dmg":905052,"bossDmg":0,"heal":61143,"deaths":0,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_6_diy1_b",0,"none",3288,1,5,80,5829,0,0,135,11],
|
["p1",1,"op","chess_char_6_diy1_b",0,"none",3288,1,5,80,5829,0,0,135,11],
|
||||||
["p1",2,"op","chess_char_6_diy2_b",1,"none",3288,1,7,19,1634,0,0,159,1],
|
["p1",2,"op","chess_char_6_diy2_b",1,"none",3288,1,7,19,1634,0,0,159,1],
|
||||||
@@ -3906,7 +3906,7 @@
|
|||||||
"hooks": [1,360,6561,1458,1458,3548,3548,181,280,345,34,30,79,12661,83,102,345,65,1,280,0,0,0],
|
"hooks": [1,360,6561,1458,1458,3548,3548,181,280,345,34,30,79,12661,83,102,345,65,1,280,0,0,0],
|
||||||
"events": {"atk":1458,"deploy":12,"die":280,"dmg":3429,"engage":6,"fx":201,"heal":181,"leak":65,"skill":64,"spawn":360,"status":185},
|
"events": {"atk":1458,"deploy":12,"die":280,"dmg":3429,"engage":6,"fx":201,"heal":181,"leak":65,"skill":64,"spawn":360,"status":185},
|
||||||
"fx": {"aoe":74,"shield":22,"summon":105},
|
"fx": {"aoe":74,"shield":22,"summon":105},
|
||||||
"snaps": "c440567a f5cbc1c4 c3321a04 8118710d 1d76d702 8b67ece4 3f62819e 179d97ba cef3a956 cdab6acc c097c581 f7b3c60a 05792236 a8a63e3a 081c51c2 c08a2e26 f4b512af 6aab85bc 79cb0a6a 2e6effc0 f37d30cd e45ba017"
|
"snaps": "c440567a f5cbc1c4 db941e1c 98161e86 7c25733a 412e9f93 345064da 20ef84d4 ddced398 f23be251 68250ac2 38b6fa07 5b3e4205 2d12aba9 a0fd9884 2c69428a 72adafce 537f7ae5 e303df67 2f32732e 76f5e13a a1202906"
|
||||||
},
|
},
|
||||||
"diy-092": {
|
"diy-092": {
|
||||||
"about": "5_diy1_b=kalts/S1/002_kalts 5_diy2_b=kalts/S2/002_kalts 5_diy1_b=kalts/S3/002_kalts 5_diy2_b=kalts/S1/003_kalts 5_diy1_b=kalts/S2/003_kalts 5_diy2_b=kalts/S3/003_kalts 5_diy1_b=kalts/S1/004_kalts 5_diy2_b=kalts/S2/004_kalts 5_diy1_b=kalts/S3/004_kalts 6_diy1_a=kalts/S1 6_diy2_a=kalts/S2 6_diy1_a=kalts/S3",
|
"about": "5_diy1_b=kalts/S1/002_kalts 5_diy2_b=kalts/S2/002_kalts 5_diy1_b=kalts/S3/002_kalts 5_diy2_b=kalts/S1/003_kalts 5_diy1_b=kalts/S2/003_kalts 5_diy2_b=kalts/S3/003_kalts 5_diy1_b=kalts/S1/004_kalts 5_diy2_b=kalts/S2/004_kalts 5_diy1_b=kalts/S3/004_kalts 6_diy1_a=kalts/S1 6_diy2_a=kalts/S2 6_diy1_a=kalts/S3",
|
||||||
@@ -4041,12 +4041,12 @@
|
|||||||
"ticks": 6603,
|
"ticks": 6603,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 49,
|
"killed": 49,
|
||||||
"total": 171,
|
"total": 93,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":49,"total":171,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":206701,"bossDmg":0,"heal":12234,"deaths":19,"layerGains":{}}},
|
"players": {"p1":{"killed":49,"total":93,"perfect":false,"leaks":122,"leaksUncounted":0,"coins":0,"dmg":206701,"bossDmg":0,"heal":12234,"deaths":19,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,161,4,8407,4272,1594,50,3],
|
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,161,4,8407,4272,1594,50,3],
|
||||||
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,127,5,10691,2678,0,44,2],
|
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,127,5,10691,2678,0,44,2],
|
||||||
@@ -4073,7 +4073,7 @@
|
|||||||
"hooks": [1,198,6603,873,873,913,886,2062,68,190,36,34,0,33122,4712,70,171,122,1,74,0,0,0],
|
"hooks": [1,198,6603,873,873,913,886,2062,68,190,36,34,0,33122,4712,70,171,122,1,74,0,0,0],
|
||||||
"events": {"atk":873,"deploy":25,"die":68,"dmg":886,"engage":12,"fx":125,"leak":122,"skill":70,"spawn":185,"status":327},
|
"events": {"atk":873,"deploy":25,"die":68,"dmg":886,"engage":12,"fx":125,"leak":122,"skill":70,"spawn":185,"status":327},
|
||||||
"fx": {"aoe":13,"dp":40,"ember":6,"revive":5,"shieldBreak":5,"summon":26,"telegraph":6,"zone":24},
|
"fx": {"aoe":13,"dp":40,"ember":6,"revive":5,"shieldBreak":5,"summon":26,"telegraph":6,"zone":24},
|
||||||
"snaps": "74b9ab2e fcc89fa5 251ebfac fd5390f8 4210aa71 cfea27b1 61084509 0c2dd520 b4b6fe6b 3299f55e 612cb6a9 da52c793 abdcdde3 6f440b10 8bc0f32b 9dc74346 477b2d00 654418b2 ec216448 d0d7e05c ee88e2cf ec291971"
|
"snaps": "74b9ab2e d85db0c0 d1c3bdb8 be104e4a c91c1023 0e638fa7 bd6273fc 6c18e1be 29b67037 ca353f68 1cf8ad1b ae165281 73511a7a 35610d7c 128ab73f 59029157 cb4fe6d3 36e9cd0b 96b30c61 bca5c5f8 c103a041 c0f160cb"
|
||||||
},
|
},
|
||||||
"diy-096": {
|
"diy-096": {
|
||||||
"about": "5_diy1_a=closur/S1 5_diy2_a=closur/S2 5_diy1_a=closur/S3 5_diy2_b=closur/S1 5_diy1_b=closur/S2 5_diy2_b=closur/S3 5_diy1_b=closur/S1/002_closur 5_diy2_b=closur/S2/002_closur 5_diy1_b=closur/S3/002_closur 6_diy1_a=closur/S1 6_diy2_a=closur/S2 6_diy1_a=closur/S3",
|
"about": "5_diy1_a=closur/S1 5_diy2_a=closur/S2 5_diy1_a=closur/S3 5_diy2_b=closur/S1 5_diy1_b=closur/S2 5_diy2_b=closur/S3 5_diy1_b=closur/S1/002_closur 5_diy2_b=closur/S2/002_closur 5_diy1_b=closur/S3/002_closur 6_diy1_a=closur/S1 6_diy2_a=closur/S2 6_diy1_a=closur/S3",
|
||||||
@@ -4874,12 +4874,12 @@
|
|||||||
"ticks": 6179,
|
"ticks": 6179,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 106,
|
"killed": 106,
|
||||||
"total": 189,
|
"total": 93,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 8,
|
"rngDraws": 8,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":106,"total":189,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":604810,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
|
"players": {"p1":{"killed":106,"total":93,"perfect":false,"leaks":83,"leaksUncounted":0,"coins":0,"dmg":604810,"bossDmg":0,"heal":0,"deaths":18,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,162,10,98104,3768,0,58,2],
|
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,162,10,98104,3768,0,58,2],
|
||||||
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,202,16,43127,3768,0,51,2],
|
["p1",2,"op","chess_char_6_diy2_a",1,null,0,0,202,16,43127,3768,0,51,2],
|
||||||
@@ -4911,7 +4911,7 @@
|
|||||||
"hooks": [1,219,6179,587,587,2744,2520,0,124,211,20,18,61,13129,23,42,189,83,1,137,0,0,0],
|
"hooks": [1,219,6179,587,587,2744,2520,0,124,211,20,18,61,13129,23,42,189,83,1,137,0,0,0],
|
||||||
"events": {"atk":973,"deploy":27,"die":128,"dmg":2520,"engage":11,"fx":145,"leak":83,"skill":38,"spawn":208,"status":442},
|
"events": {"atk":973,"deploy":27,"die":128,"dmg":2520,"engage":11,"fx":145,"leak":83,"skill":38,"spawn":208,"status":442},
|
||||||
"fx": {"aoe":4,"dash":3,"displace":8,"ember":13,"pull":8,"shieldBreak":36,"summon":36,"takeoff":9,"telegraph":13,"volley":15},
|
"fx": {"aoe":4,"dash":3,"displace":8,"ember":13,"pull":8,"shieldBreak":36,"summon":36,"takeoff":9,"telegraph":13,"volley":15},
|
||||||
"snaps": "39df8f19 dc0b29a2 9e238c17 43dd1e57 a6a34d5f a555ab38 25a0c85d 1ad33783 acbe4560 13b28161 da34a727 b95db8d6 23e509f7 faaca8f2 9f5d7dc2 a8cc015f 4ced24ff 3dece084 287324b5 6dbabf08 a99101bd"
|
"snaps": "216e6311 22bbd174 e2861a2f db9fa2cb 9ccc6a30 fe834987 143b56d4 3e1bf123 0a9fb02b eb5b35d6 033fff75 60f27492 bf703ce7 93e8e5aa eb86edae eeb8fb64 a361acb7 20b6a8cd f300097c f0f3ad12 0ab29755"
|
||||||
},
|
},
|
||||||
"diy-115": {
|
"diy-115": {
|
||||||
"about": "5_diy1_b=ebnhlz/S1/002_ebnhlz 5_diy2_b=ebnhlz/S2/002_ebnhlz 5_diy1_b=ebnhlz/S3/002_ebnhlz 5_diy2_b=ebnhlz/S1/003_ebnhlz 5_diy1_b=ebnhlz/S2/003_ebnhlz 5_diy2_b=ebnhlz/S3/003_ebnhlz 5_diy1_b=ebnhlz/S1/004_ebnhlz 5_diy2_b=ebnhlz/S2/004_ebnhlz 5_diy1_b=ebnhlz/S3/004_ebnhlz 6_diy1_a=ebnhlz/S1 6_diy2_a=ebnhlz/S2 6_diy1_a=ebnhlz/S3",
|
"about": "5_diy1_b=ebnhlz/S1/002_ebnhlz 5_diy2_b=ebnhlz/S2/002_ebnhlz 5_diy1_b=ebnhlz/S3/002_ebnhlz 5_diy2_b=ebnhlz/S1/003_ebnhlz 5_diy1_b=ebnhlz/S2/003_ebnhlz 5_diy2_b=ebnhlz/S3/003_ebnhlz 5_diy1_b=ebnhlz/S1/004_ebnhlz 5_diy2_b=ebnhlz/S2/004_ebnhlz 5_diy1_b=ebnhlz/S3/004_ebnhlz 6_diy1_a=ebnhlz/S1 6_diy2_a=ebnhlz/S2 6_diy1_a=ebnhlz/S3",
|
||||||
@@ -4926,12 +4926,12 @@
|
|||||||
"ticks": 6168,
|
"ticks": 6168,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 154,
|
"killed": 154,
|
||||||
"total": 232,
|
"total": 96,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":154,"total":232,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":511834,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}},
|
"players": {"p1":{"killed":154,"total":96,"perfect":false,"leaks":78,"leaksUncounted":0,"coins":0,"dmg":511834,"bossDmg":0,"heal":0,"deaths":10,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_ebnhlz",1570,1,10,4,57606,0,0,47,5],
|
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_ebnhlz",1570,1,10,4,57606,0,0,47,5],
|
||||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ebnhlz",1570,1,11,7,31893,0,0,29,0],
|
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_ebnhlz",1570,1,11,7,31893,0,0,29,0],
|
||||||
@@ -4978,7 +4978,7 @@
|
|||||||
"hooks": [1,273,6168,461,461,2423,2548,0,164,261,49,47,0,30421,0,28,232,78,1,164,0,0,0],
|
"hooks": [1,273,6168,461,461,2423,2548,0,164,261,49,47,0,30421,0,28,232,78,1,164,0,0,0],
|
||||||
"events": {"atk":461,"deploy":41,"die":183,"dmg":2548,"engage":11,"fx":193,"leak":78,"skill":96,"spawn":265},
|
"events": {"atk":461,"deploy":41,"die":183,"dmg":2548,"engage":11,"fx":193,"leak":78,"skill":96,"spawn":265},
|
||||||
"fx": {"aoe":13,"displace":17,"pull":17,"summon":82,"volley":64},
|
"fx": {"aoe":13,"displace":17,"pull":17,"summon":82,"volley":64},
|
||||||
"snaps": "948c5168 c32d266d 43ba134b 4372fae1 6e4bf334 4c5fe4c1 6dfbc99b bc7d903c 631d9949 3a438571 2e0c2541 2a1dae92 a90b81e6 ca4ec2b1 454f0153 6e8de2d3 0dd5e1fa d3cd56d3 feeb4a26 da6eab43 8a27c708"
|
"snaps": "25481013 3b3236fa f6b7b1d9 80f81d61 34ef1943 fc11110a a08ee007 369e405c 41ff7208 5e487926 dafe54d3 1b0b63a8 f995283d 0cacb5c4 02b48bb8 4fb3f62d 355c0b0f e3d263b8 07e90d84 8b402749 0f3160ec"
|
||||||
},
|
},
|
||||||
"diy-116": {
|
"diy-116": {
|
||||||
"about": "6_diy1_b=ebnhlz/S1 6_diy2_b=ebnhlz/S2 6_diy1_b=ebnhlz/S3 6_diy2_b=ebnhlz/S1/002_ebnhlz 6_diy1_b=ebnhlz/S2/002_ebnhlz 6_diy2_b=ebnhlz/S3/002_ebnhlz 6_diy1_b=ebnhlz/S1/003_ebnhlz 6_diy2_b=ebnhlz/S2/003_ebnhlz 6_diy1_b=ebnhlz/S3/003_ebnhlz 6_diy2_b=ebnhlz/S1/004_ebnhlz 6_diy1_b=ebnhlz/S2/004_ebnhlz 6_diy2_b=ebnhlz/S3/004_ebnhlz",
|
"about": "6_diy1_b=ebnhlz/S1 6_diy2_b=ebnhlz/S2 6_diy1_b=ebnhlz/S3 6_diy2_b=ebnhlz/S1/002_ebnhlz 6_diy1_b=ebnhlz/S2/002_ebnhlz 6_diy2_b=ebnhlz/S3/002_ebnhlz 6_diy1_b=ebnhlz/S1/003_ebnhlz 6_diy2_b=ebnhlz/S2/003_ebnhlz 6_diy1_b=ebnhlz/S3/003_ebnhlz 6_diy2_b=ebnhlz/S1/004_ebnhlz 6_diy1_b=ebnhlz/S2/004_ebnhlz 6_diy2_b=ebnhlz/S3/004_ebnhlz",
|
||||||
|
|||||||
+33
-33
@@ -21,13 +21,13 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 20,
|
"killed": 20,
|
||||||
"total": 20,
|
"total": 16,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 860227,
|
"bossHpLeft": 860227,
|
||||||
"rngDraws": 465,
|
"rngDraws": 465,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":20,"total":20,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1654181,"bossDmg":1231515,"heal":19498,"deaths":1,"layerGains":{}},
|
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1654181,"bossDmg":1231515,"heal":19498,"deaths":1,"layerGains":{}},
|
||||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1792443,"bossDmg":1508258,"heal":66028,"deaths":1,"layerGains":{}}
|
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1792443,"bossDmg":1508258,"heal":66028,"deaths":1,"layerGains":{}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"hooks": [1,72,6000,1206,1206,3099,3496,3972,31,41,95,89,9,39824,2903,15,29,0,1,32,0,23,0],
|
"hooks": [1,72,6000,1206,1206,3099,3496,3972,31,41,95,89,9,39824,2903,15,29,0,1,32,0,23,0],
|
||||||
"events": {"atk":1542,"deploy":37,"die":41,"dmg":3212,"engage":18,"fx":658,"heal":385,"skill":184,"spawn":69,"status":710},
|
"events": {"atk":1542,"deploy":37,"die":41,"dmg":3212,"engage":18,"fx":658,"heal":385,"skill":184,"spawn":69,"status":710},
|
||||||
"fx": {"airstrike":5,"beam":54,"blessing":1,"blink":1,"bombard":9,"bombardShell":9,"burst":23,"copy":3,"dp":4,"drone":335,"droneLock":17,"drones":3,"finale":2,"firewall":1,"garrisonGrant":1,"lightning":124,"link":1,"lock":9,"radiantSword":4,"shell":8,"slash":2,"snow":5,"split":3,"storm":10,"summon":8,"talent":4,"tide":5,"wanted":7},
|
"fx": {"airstrike":5,"beam":54,"blessing":1,"blink":1,"bombard":9,"bombardShell":9,"burst":23,"copy":3,"dp":4,"drone":335,"droneLock":17,"drones":3,"finale":2,"firewall":1,"garrisonGrant":1,"lightning":124,"link":1,"lock":9,"radiantSword":4,"shell":8,"slash":2,"snow":5,"split":3,"storm":10,"summon":8,"talent":4,"tide":5,"wanted":7},
|
||||||
"snaps": "4b23de24 b826ead4 8478a42a 5f198411 d8f2bcc3 1887ff31 4c303f89 eb6f8150 4dfd4f69 9992c24d 2addc854 8d1008cc 5356698a b471ba51 316422fb c3bf9985 bfae7486 4e4d06f2 d87e4df8 2727e182"
|
"snaps": "44bc2b70 b3daaf96 baf6929d 143779f3 c1387369 80ca050e 4e208395 6728039d 417e6ec7 e83606e2 06a61aec 78738a35 a6c6ae9c c7844f12 05ba7c16 89e6563a eafb1f1c 5f815a0a f048b25a cef59d00"
|
||||||
},
|
},
|
||||||
"boss-boss_1-solo": {
|
"boss-boss_1-solo": {
|
||||||
"about": "假想敌:胄 solo pool 1800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
"about": "假想敌:胄 solo pool 1800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||||
@@ -97,12 +97,12 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 17,
|
"killed": 17,
|
||||||
"total": 17,
|
"total": 13,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 1047606,
|
"bossHpLeft": 1047606,
|
||||||
"rngDraws": 51,
|
"rngDraws": 51,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":966812,"bossDmg":752394,"heal":37356,"deaths":0,"layerGains":{}}},
|
"players": {"p1":{"killed":17,"total":13,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":966812,"bossDmg":752394,"heal":37356,"deaths":0,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,3,1,7118,3554,0,5,1],
|
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,3,1,7118,3554,0,5,1],
|
||||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,5,0,0,4111,23085,0,35],
|
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,5,0,0,4111,23085,0,35],
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"hooks": [1,36,6000,693,693,1031,1120,3142,24,24,60,59,168,20939,2386,0,26,0,1,24,0,0,0],
|
"hooks": [1,36,6000,693,693,1031,1120,3142,24,24,60,59,168,20939,2386,0,26,0,1,24,0,0,0],
|
||||||
"events": {"atk":709,"deploy":10,"die":24,"dmg":1060,"engage":6,"fx":278,"heal":148,"skill":119,"spawn":36,"status":286},
|
"events": {"atk":709,"deploy":10,"die":24,"dmg":1060,"engage":6,"fx":278,"heal":148,"skill":119,"spawn":36,"status":286},
|
||||||
"fx": {"anchor":3,"beam":54,"camouflage":1,"healAoe":35,"overload":1,"shell":8,"splash":168,"substitute":2,"summon":4,"swap":2},
|
"fx": {"anchor":3,"beam":54,"camouflage":1,"healAoe":35,"overload":1,"shell":8,"splash":168,"substitute":2,"summon":4,"swap":2},
|
||||||
"snaps": "e67f9eea 2430c808 1d53d0cb 883a39b4 e2e83b6d 8d8ac019 679768b3 0e10a007 f2a7dd03 e508cc03 00b8aaac c75a3d01 837a2744 4e854516 4dc32e71 f923cb9e f9c21aa0 de1d717c 454f544e ab149f66"
|
"snaps": "5160aa85 7e265445 0be2d2e9 6c0188d5 103cf813 9aace512 f579cdb4 8ee9aa58 8005f42d ba1509c1 5738e08d a56aea09 c76253e8 4d964f19 99f8d5c9 4cc5c3f9 9ea422bb 63e8d010 53339522 4b7760db"
|
||||||
},
|
},
|
||||||
"boss-boss_2-pair": {
|
"boss-boss_2-pair": {
|
||||||
"about": "假想敌:铳 pair pool 6000000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
"about": "假想敌:铳 pair pool 6000000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||||
@@ -262,13 +262,13 @@
|
|||||||
"ticks": 5803,
|
"ticks": 5803,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 19,
|
"killed": 19,
|
||||||
"total": 21,
|
"total": 6,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 0,
|
"bossHpLeft": 0,
|
||||||
"rngDraws": 1423,
|
"rngDraws": 1423,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":19,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1572377,"bossDmg":1451138,"heal":8263,"deaths":4,"layerGains":{}},
|
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1572377,"bossDmg":1451138,"heal":8263,"deaths":4,"layerGains":{}},
|
||||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2612061,"bossDmg":2548862,"heal":37169,"deaths":3,"layerGains":{}}
|
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2612061,"bossDmg":2548862,"heal":37169,"deaths":3,"layerGains":{}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
"tide": 3,
|
"tide": 3,
|
||||||
"wanted": 1
|
"wanted": 1
|
||||||
},
|
},
|
||||||
"snaps": "8c6eb9d3 f5f9a5b4 160c191e 00bb0683 7dd5622f 4b0281e0 6b9388d5 d238f18e 0d5c7c32 c5a0818c 04311046 f68b2536 1eb1629f b8281779 5c9abae1 b74b5a58 4ca28388 6c1d2341 c8e29d0c 0892f5e8"
|
"snaps": "8c6eb9d3 796da093 9e3c16b7 fa7000d5 b0ba5df0 c13bfdd0 b5a04ea3 1a096951 99f7023b ee7eca69 27c012ea 189a5d94 675b6a80 c16a2d26 fb45381b dc80cb4d 62258e5c f5fd826c 841da98a c3777617"
|
||||||
},
|
},
|
||||||
"boss-boss_3-solo": {
|
"boss-boss_3-solo": {
|
||||||
"about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
"about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||||
@@ -375,12 +375,12 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 15,
|
"killed": 15,
|
||||||
"total": 15,
|
"total": 9,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 1281945,
|
"bossHpLeft": 1281945,
|
||||||
"rngDraws": 11,
|
"rngDraws": 11,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":15,"total":15,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":790763,"bossDmg":718055,"heal":12252,"deaths":2,"layerGains":{}}},
|
"players": {"p1":{"killed":15,"total":9,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":790763,"bossDmg":718055,"heal":12252,"deaths":2,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,11,0,187454,8125,0,133,4],
|
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,11,0,187454,8125,0,133,4],
|
||||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,9,0,0,0,8390,0,18],
|
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,9,0,0,0,8390,0,18],
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"hooks": [1,42,6000,403,403,736,1597,1098,20,24,64,63,21,16163,1212,2,19,0,1,153,0,1,0],
|
"hooks": [1,42,6000,403,403,736,1597,1098,20,24,64,63,21,16163,1212,2,19,0,1,153,0,1,0],
|
||||||
"events": {"atk":422,"deploy":17,"die":24,"dmg":716,"engage":8,"fx":217,"heal":42,"skill":127,"spawn":36,"status":180},
|
"events": {"atk":422,"deploy":17,"die":24,"dmg":716,"engage":8,"fx":217,"heal":42,"skill":127,"spawn":36,"status":180},
|
||||||
"fx": {"anchor":1,"aoe":3,"beam":6,"burst":1,"candle":3,"ember":3,"explode":132,"healAoe":18,"overload":4,"phase":13,"revive":1,"splash":21,"substitute":1,"summon":6,"swap":1,"telegraph":1,"teleport":2},
|
"fx": {"anchor":1,"aoe":3,"beam":6,"burst":1,"candle":3,"ember":3,"explode":132,"healAoe":18,"overload":4,"phase":13,"revive":1,"splash":21,"substitute":1,"summon":6,"swap":1,"telegraph":1,"teleport":2},
|
||||||
"snaps": "edad47f7 eb7e962c 901e9a90 f6931b7e ebcc98aa 25e8d278 8613cfef ec8ed003 b9a58927 9405e611 adc77694 58659919 2f682a2d 7386dac2 87591558 f5f68337 ad5b46f9 a460afb6 0337e937 99d41b9c"
|
"snaps": "edad47f7 646819de f70d4209 623036bb 007bda8c c32ef17d 45a767ca ba1d4895 692a0b5b 00e8b931 0257c6ee b3448033 79f115e6 61c7911a e82a3934 95da73a6 72fc7d06 d6376b51 d8d5a6a7 2e2cb739"
|
||||||
},
|
},
|
||||||
"boss-boss_4-pair": {
|
"boss-boss_4-pair": {
|
||||||
"about": "盐风主教昆图斯 pair pool 8400000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
"about": "盐风主教昆图斯 pair pool 8400000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||||
@@ -553,13 +553,13 @@
|
|||||||
"ticks": 3856,
|
"ticks": 3856,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 18,
|
"killed": 18,
|
||||||
"total": 19,
|
"total": 8,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 0,
|
"bossHpLeft": 0,
|
||||||
"rngDraws": 1055,
|
"rngDraws": 1055,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":18,"total":19,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1203964,"bossDmg":884209,"heal":18083,"deaths":2,"layerGains":{}},
|
"p1": {"killed":18,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1203964,"bossDmg":884209,"heal":18083,"deaths":2,"layerGains":{}},
|
||||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1184403,"bossDmg":675791,"heal":65228,"deaths":1,"layerGains":{}}
|
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1184403,"bossDmg":675791,"heal":65228,"deaths":1,"layerGains":{}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
@@ -642,7 +642,7 @@
|
|||||||
"tide": 3,
|
"tide": 3,
|
||||||
"wanted": 7
|
"wanted": 7
|
||||||
},
|
},
|
||||||
"snaps": "7db84769 07f86d88 93f09fef c359f639 8c1c3866 2e4a5893 f241408f 1d2dafff 2e0c6f08 0716004e 656c6621 e7fc5f6b 5fdc9566"
|
"snaps": "7db84769 ccd2ecf5 49d646d8 1185f93a 64db5dca 1a9e67db 3a4e3045 5b3ba069 0dcc32cf 7d6016ed 4061bb14 3c99a82f a50f8c81"
|
||||||
},
|
},
|
||||||
"boss-boss_5-solo": {
|
"boss-boss_5-solo": {
|
||||||
"about": "卢西恩,“猩红血钻” solo pool 780000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
"about": "卢西恩,“猩红血钻” solo pool 780000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||||
@@ -657,12 +657,12 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 10,
|
"killed": 10,
|
||||||
"total": 11,
|
"total": 5,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 466087,
|
"bossHpLeft": 466087,
|
||||||
"rngDraws": 291,
|
"rngDraws": 291,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":10,"total":11,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":476733,"bossDmg":313913,"heal":44154,"deaths":2,"layerGains":{}}},
|
"players": {"p1":{"killed":10,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":476733,"bossDmg":313913,"heal":44154,"deaths":2,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",0,0,2,1,103410,6835,4077,56,3],
|
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",0,0,2,1,103410,6835,4077,56,3],
|
||||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,1,0,0,0,12704,0,25],
|
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,1,0,0,0,12704,0,25],
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
"hooks": [1,30,6000,357,357,687,1120,4237,14,18,58,57,92,17108,995,17,14,0,1,60,0,5,47],
|
"hooks": [1,30,6000,357,357,687,1120,4237,14,18,58,57,92,17108,995,17,14,0,1,60,0,5,47],
|
||||||
"events": {"atk":367,"deploy":16,"die":18,"dmg":806,"engage":9,"fx":221,"heal":249,"skill":115,"spawn":27,"status":191},
|
"events": {"atk":367,"deploy":16,"die":18,"dmg":806,"engage":9,"fx":221,"heal":249,"skill":115,"spawn":27,"status":191},
|
||||||
"fx": {"anchor":1,"aoe":1,"blink":6,"bloodBattle":1,"burst":5,"candle":2,"dodge":47,"ember":1,"explode":24,"healAoe":25,"overload":3,"shieldBreak":2,"splash":92,"summon":8,"telegraph":1,"teleport":2},
|
"fx": {"anchor":1,"aoe":1,"blink":6,"bloodBattle":1,"burst":5,"candle":2,"dodge":47,"ember":1,"explode":24,"healAoe":25,"overload":3,"shieldBreak":2,"splash":92,"summon":8,"telegraph":1,"teleport":2},
|
||||||
"snaps": "20a08a18 81858159 9443afc2 37c4a489 c77051bb 940bfbc5 d27ac583 503cf292 a5771a92 598f6f84 ce5a0bfd bd96c315 8f1d4a4b 387b9dbd 9e4cb6fb e7146e90 3a787983 7725c0a5 b8f07b0b a90fc040"
|
"snaps": "20a08a18 e7c4e5c0 19953744 0b473fe3 5361c85f cb383aa0 6804cf6e 34e854d8 abc09b9d 4729ee83 d3e3e78f bf8d4ce2 824cc2db fcf4e1ba 663a28e6 358410c3 de4f40de 6c169100 7b105e25 70e4dc0f"
|
||||||
},
|
},
|
||||||
"boss-boss_6-pair": {
|
"boss-boss_6-pair": {
|
||||||
"about": "阿利斯泰尔,帝国余晖 pair pool 7960000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
"about": "阿利斯泰尔,帝国余晖 pair pool 7960000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||||
@@ -797,12 +797,12 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 5,
|
"killed": 5,
|
||||||
"total": 10,
|
"total": 4,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 3874806,
|
"bossHpLeft": 3874806,
|
||||||
"rngDraws": 45,
|
"rngDraws": 45,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":5,"total":10,"perfect":false,"leaks":5,"leaksUncounted":0,"coins":0,"dmg":152625,"bossDmg":105194,"heal":1783,"deaths":16,"layerGains":{}}},
|
"players": {"p1":{"killed":5,"total":4,"perfect":false,"leaks":5,"leaksUncounted":0,"coins":0,"dmg":152625,"bossDmg":105194,"heal":1783,"deaths":16,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3513,1,10,2,8,0,0,8,0],
|
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3513,1,10,2,8,0,0,8,0],
|
||||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",6104,1,11,0,6,0,0,6,1],
|
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",6104,1,11,0,6,0,0,6,1],
|
||||||
@@ -833,7 +833,7 @@
|
|||||||
"hooks": [1,49,6000,179,179,275,472,1334,29,34,20,20,0,6463,12,1,19,5,1,21,0,0,3],
|
"hooks": [1,49,6000,179,179,275,472,1334,29,34,20,20,0,6463,12,1,19,5,1,21,0,0,3],
|
||||||
"events": {"atk":205,"deploy":24,"die":29,"dmg":201,"engage":7,"fx":91,"leak":5,"skill":42,"spawn":35,"status":73},
|
"events": {"atk":205,"deploy":24,"die":29,"dmg":201,"engage":7,"fx":91,"leak":5,"skill":42,"spawn":35,"status":73},
|
||||||
"fx": {"dodge":3,"equip":9,"featherArrow":7,"flame":3,"garrisonGrant":1,"jungleSoul":2,"mote":8,"riposte":3,"rockslide":1,"sandChains":3,"slash":29,"summon":2,"sword":3,"telegraph":10,"truesilver":4,"vest":3},
|
"fx": {"dodge":3,"equip":9,"featherArrow":7,"flame":3,"garrisonGrant":1,"jungleSoul":2,"mote":8,"riposte":3,"rockslide":1,"sandChains":3,"slash":29,"summon":2,"sword":3,"telegraph":10,"truesilver":4,"vest":3},
|
||||||
"snaps": "3e08ed3d 2a259699 77c4f1c2 a547c8e8 80cdbe94 5387ac7c ac8acb26 e405b502 b8cb4679 62dee17d 6a983199 49974232 ee10619f d218a36a 29eef739 77b2f124 b45c9755 95f6faee a7fcd01a edbf901f"
|
"snaps": "3e08ed3d b0f5c6bf 12616b1d 9cce9eed afcfc076 fb56bae7 617f9500 b046bb87 398d9618 80f1089f 99802228 823c06f9 a478439e 71b3d152 452c5be2 683a7bbf 1f49e639 2d0e0575 72ea261c 8fc27088"
|
||||||
},
|
},
|
||||||
"boss-boss_7-pair": {
|
"boss-boss_7-pair": {
|
||||||
"about": "“萨米的意志” pair pool 4000000: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b token_10017_skadi2_dedant token_10030_mlyss_wtrman | chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_6_17_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b",
|
"about": "“萨米的意志” pair pool 4000000: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b token_10017_skadi2_dedant token_10030_mlyss_wtrman | chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_6_17_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b",
|
||||||
@@ -957,13 +957,13 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 16,
|
"killed": 16,
|
||||||
"total": 18,
|
"total": 14,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 12585770,
|
"bossHpLeft": 12585770,
|
||||||
"rngDraws": 666,
|
"rngDraws": 666,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":16,"total":18,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2677042,"bossDmg":1550396,"heal":69312,"deaths":17,"layerGains":{}},
|
"p1": {"killed":16,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2677042,"bossDmg":1550396,"heal":69312,"deaths":17,"layerGains":{}},
|
||||||
"p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":560253,"bossDmg":262771,"heal":9942,"deaths":25,"layerGains":{}}
|
"p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":560253,"bossDmg":262771,"heal":9942,"deaths":25,"layerGains":{}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
@@ -1044,7 +1044,7 @@
|
|||||||
"weightless": 3,
|
"weightless": 3,
|
||||||
"zone": 180
|
"zone": 180
|
||||||
},
|
},
|
||||||
"snaps": "cac31b2c d1e28667 87626189 11a56b43 640cf8cf 9a92be69 12784d9a 195ea951 99de9374 65451b7e 90846f89 9bcf64aa 0cf3dfb9 5737ca93 68f962d8 98c8303b e5af0a0e 3c947908 ce14aa80 61dc2e23"
|
"snaps": "0499777c 66895c47 00cd2e8d 02a8aa95 5d3f3829 3d4bf1c3 1ecb5a32 b9d715e5 54e94d88 7c9b3f95 de81172e 15cddd71 78890306 d4586c67 72920db0 61a8a2c6 4a931d91 d389bff6 1cdd3841 f76bc0a3"
|
||||||
},
|
},
|
||||||
"hidden-boss_8-solo": {
|
"hidden-boss_8-solo": {
|
||||||
"about": "假想敌:胄 solo pool 7200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
"about": "假想敌:胄 solo pool 7200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
||||||
@@ -1059,12 +1059,12 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 3,
|
"killed": 3,
|
||||||
"total": 13,
|
"total": 6,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 6797394,
|
"bossHpLeft": 6797394,
|
||||||
"rngDraws": 227,
|
"rngDraws": 227,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":3,"total":13,"perfect":false,"leaks":10,"leaksUncounted":0,"coins":0,"dmg":450453,"bossDmg":402606,"heal":28293,"deaths":23,"layerGains":{}}},
|
"players": {"p1":{"killed":3,"total":6,"perfect":false,"leaks":10,"leaksUncounted":0,"coins":0,"dmg":450453,"bossDmg":402606,"heal":28293,"deaths":23,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",0,0,6,0,264,7875,0,4,0],
|
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",0,0,6,0,264,7875,0,4,0],
|
||||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",0,0,32,1,13954,18678,6470,10,5],
|
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",0,0,32,1,13954,18678,6470,10,5],
|
||||||
@@ -1094,7 +1094,7 @@
|
|||||||
"hooks": [1,56,6000,303,303,564,576,1204,37,47,17,17,0,5895,50,3,28,10,1,29,0,0,0],
|
"hooks": [1,56,6000,303,303,564,576,1204,37,47,17,17,0,5895,50,3,28,10,1,29,0,0,0],
|
||||||
"events": {"atk":360,"deploy":24,"die":37,"dmg":569,"engage":5,"fx":188,"heal":76,"leak":10,"skill":35,"spawn":42,"status":149},
|
"events": {"atk":360,"deploy":24,"die":37,"dmg":569,"engage":5,"fx":188,"heal":76,"leak":10,"skill":35,"spawn":42,"status":149},
|
||||||
"fx": {"bandage":1,"beam":34,"blink":11,"block":6,"charge":6,"explode":11,"garrisonGrant":1,"healField":6,"mote":4,"redistribute":5,"reweave":1,"rockslide":5,"sandChains":1,"shell":7,"shieldBreak":7,"slash":58,"spGift":2,"summon":5,"telegraph":3,"thorns":11,"truesilver":3},
|
"fx": {"bandage":1,"beam":34,"blink":11,"block":6,"charge":6,"explode":11,"garrisonGrant":1,"healField":6,"mote":4,"redistribute":5,"reweave":1,"rockslide":5,"sandChains":1,"shell":7,"shieldBreak":7,"slash":58,"spGift":2,"summon":5,"telegraph":3,"thorns":11,"truesilver":3},
|
||||||
"snaps": "cf292ec5 7036981d ce112fc3 25f0eb93 7e29d0f3 a4667973 30a2224e 8827f472 5ed6aeed 09c8a1e1 a4c3c74f 484ec38d cf5610d2 28371e20 bee6b488 d24d7a86 3e0c2516 47741f73 949b25ac 7b412345"
|
"snaps": "9b5e58cf c4e06fea a40166a3 0e30f917 42214d72 ebc20f66 7258ad62 49f2a70f 0b93ecfb 8a8ebc4e 4bf3c623 15ee0c07 41d58e70 7b48444f 6d3425f8 2d1f93c0 a755c2ad 76e23872 1016f3ce e5423b09"
|
||||||
},
|
},
|
||||||
"hidden-boss_9-pair": {
|
"hidden-boss_9-pair": {
|
||||||
"about": "假想敌:铳 pair pool 5600000: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b token_10017_skadi2_dedant token_10030_mlyss_wtrman | chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_6_17_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b",
|
"about": "假想敌:铳 pair pool 5600000: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b token_10017_skadi2_dedant token_10030_mlyss_wtrman | chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_6_17_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b",
|
||||||
@@ -1272,13 +1272,13 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 10,
|
"killed": 10,
|
||||||
"total": 12,
|
"total": 4,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 14635261,
|
"bossHpLeft": 14635261,
|
||||||
"rngDraws": 918,
|
"rngDraws": 918,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":10,"total":12,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":459267,"bossDmg":234580,"heal":65462,"deaths":5,"layerGains":{}},
|
"p1": {"killed":10,"total":4,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":459267,"bossDmg":234580,"heal":65462,"deaths":5,"layerGains":{}},
|
||||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":520827,"bossDmg":330160,"heal":13987,"deaths":24,"layerGains":{}}
|
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":520827,"bossDmg":330160,"heal":13987,"deaths":24,"layerGains":{}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
@@ -1360,7 +1360,7 @@
|
|||||||
"weightless": 2,
|
"weightless": 2,
|
||||||
"zone": 200
|
"zone": 200
|
||||||
},
|
},
|
||||||
"snaps": "f891236c f6672d61 695d2e11 dd4444a7 42309327 9cec6e57 b8fe3b31 cfaa0d6b 4be820e6 693491bb cddbaa3a 1e1a7871 945cbd12 d9b820e0 034525ef 35395d78 56b4abc5 8f47eaee d52f39d7 520c8b02"
|
"snaps": "f891236c f6672d61 695d2e11 dd4444a7 2a7dde13 b3213880 431cd2c4 6abcdf88 03c938b6 2adbab93 89502717 3fc3d404 0c63a20c 8d49e8fc f8eb3fc4 640373e1 0243b53a 3b3694f0 569f5d6c 6ebfcb06"
|
||||||
},
|
},
|
||||||
"hidden-boss_10-solo": {
|
"hidden-boss_10-solo": {
|
||||||
"about": "假想敌:管 solo pool 7600000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
"about": "假想敌:管 solo pool 7600000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
||||||
@@ -1375,12 +1375,12 @@
|
|||||||
"ticks": 6000,
|
"ticks": 6000,
|
||||||
"reason": "forced",
|
"reason": "forced",
|
||||||
"killed": 11,
|
"killed": 11,
|
||||||
"total": 13,
|
"total": 5,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": 7323878,
|
"bossHpLeft": 7323878,
|
||||||
"rngDraws": 102,
|
"rngDraws": 102,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":11,"total":13,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":430741,"bossDmg":276122,"heal":148595,"deaths":10,"layerGains":{}}},
|
"players": {"p1":{"killed":11,"total":5,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":430741,"bossDmg":276122,"heal":148595,"deaths":10,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3123.26,1,29,5,45,3903,0,19,0],
|
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3123.26,1,29,5,45,3903,0,19,0],
|
||||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",1092.31,1,24,0,36,24428,13312,31,6],
|
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",1092.31,1,24,0,36,24428,13312,31,6],
|
||||||
@@ -1403,7 +1403,7 @@
|
|||||||
"hooks": [1,33,6000,650,650,2402,2852,7246,21,22,33,33,0,9568,105,16,15,1,1,65,0,27,2],
|
"hooks": [1,33,6000,650,650,2402,2852,7246,21,22,33,33,0,9568,105,16,15,1,1,65,0,27,2],
|
||||||
"events": {"atk":973,"deploy":18,"die":21,"dmg":2838,"engage":7,"fx":844,"heal":298,"leak":1,"skill":69,"spawn":25,"status":303},
|
"events": {"atk":973,"deploy":18,"die":21,"dmg":2838,"engage":7,"fx":844,"heal":298,"leak":1,"skill":69,"spawn":25,"status":303},
|
||||||
"fx": {"bandage":9,"beam":21,"block":14,"burst":27,"dodge":2,"ember":2,"explode":500,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":3,"mote":52,"phase":55,"redistribute":15,"reweave":1,"riposte":1,"rockslide":6,"sandChains":1,"sandChainsCharged":3,"shieldBreak":21,"slash":40,"spGift":2,"summon":8,"telegraph":2,"thorns":47,"truesilver":2,"undying":1},
|
"fx": {"bandage":9,"beam":21,"block":14,"burst":27,"dodge":2,"ember":2,"explode":500,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":3,"mote":52,"phase":55,"redistribute":15,"reweave":1,"riposte":1,"rockslide":6,"sandChains":1,"sandChainsCharged":3,"shieldBreak":21,"slash":40,"spGift":2,"summon":8,"telegraph":2,"thorns":47,"truesilver":2,"undying":1},
|
||||||
"snaps": "65ab7720 4d0f9bc6 b175a7ad decfc6d9 b1fdbc1b 7b25f64c 67b47291 502d1b71 8861598b 7112c963 3170b708 8e72387d c384da93 0d0d33a6 19ea6be5 8daa9858 108f2f2f 471283be f8dc4d37 8c04e516"
|
"snaps": "65ab7720 4d0f9bc6 b175a7ad decfc6d9 d1ad8895 b6add8db 8b187de4 1339864b 84136f5a 963ae966 244b6531 f0d249a1 55dc7a49 f44509fa 5ba1fdbd c65a6ccf 00d4edb3 c6669080 45cab92a 414911cc"
|
||||||
},
|
},
|
||||||
"unite-1": {
|
"unite-1": {
|
||||||
"about": "联防 1 helper(s), 11 leaked: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b token_10017_skadi2_dedant",
|
"about": "联防 1 helper(s), 11 leaked: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b token_10017_skadi2_dedant",
|
||||||
|
|||||||
+507
-513
File diff suppressed because it is too large.
Load diff
+69
-69
@@ -79,12 +79,12 @@
|
|||||||
"ticks": 3300,
|
"ticks": 3300,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 29,
|
"killed": 29,
|
||||||
"total": 44,
|
"total": 43,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 88,
|
"rngDraws": 88,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":29,"total":44,"perfect":false,"leaks":15,"leaksUncounted":0,"coins":8,"dmg":264848,"bossDmg":0,"heal":6152,"deaths":14,"layerGains":{}}},
|
"players": {"p1":{"killed":29,"total":43,"perfect":false,"leaks":15,"leaksUncounted":0,"coins":8,"dmg":264848,"bossDmg":0,"heal":6152,"deaths":14,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_1_02_b",1,"uniequip_002_yak",3645.23,1,63,2,9693,5979,0,26,1],
|
["p1",1,"op","chess_char_1_02_b",1,"uniequip_002_yak",3645.23,1,63,2,9693,5979,0,26,1],
|
||||||
["p1",2,"op","chess_char_1_20_a",1,null,2857.4,1,1,0,0,0,0,0,0],
|
["p1",2,"op","chess_char_1_20_a",1,null,2857.4,1,1,0,0,0,0,0,0],
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
"hooks": [1,64,3300,376,376,473,517,3286,43,54,16,18,48,7606,34,12,44,11,1,44,0,0,0],
|
"hooks": [1,64,3300,376,376,473,517,3286,43,54,16,18,48,7606,34,12,44,11,1,44,0,0,0],
|
||||||
"events": {"atk":398,"bounty":4,"deploy":20,"die":43,"dmg":517,"engage":10,"fx":83,"heal":36,"leak":11,"skill":37,"spawn":56,"status":68},
|
"events": {"atk":398,"bounty":4,"deploy":20,"die":43,"dmg":517,"engage":10,"fx":83,"heal":36,"leak":11,"skill":37,"spawn":56,"status":68},
|
||||||
"fx": {"aoe":5,"counter":14,"crit":23,"displace":10,"dp":3,"ember":1,"pulse":22,"revive":1,"sp":1,"summon":1,"taunt":2},
|
"fx": {"aoe":5,"counter":14,"crit":23,"displace":10,"dp":3,"ember":1,"pulse":22,"revive":1,"sp":1,"summon":1,"taunt":2},
|
||||||
"snaps": "e780a460 61ee47ae bdf76d96 06fbd6a7 1037bf32 a42ebfa2 12a813ce ef2d4564 747331aa a207b3fb d814a43b"
|
"snaps": "e780a460 61ee47ae bdf76d96 06fbd6a7 1037bf32 f06a06c4 cbd23de7 17d297e3 95dffa16 fe0088d3 7c8c9766"
|
||||||
},
|
},
|
||||||
"roster-003": {
|
"roster-003": {
|
||||||
"about": "pass 0 band 2: chess_char_1_10_b chess_char_1_12_b chess_char_1_20_b chess_char_1_18_b chess_char_1_19_b chess_char_1_13_b chess_char_1_14_b chess_char_1_17_b chess_char_2_01_a chess_char_2_02_a chess_char_2_04_a chess_char_2_05_a token_10000_silent_healrb",
|
"about": "pass 0 band 2: chess_char_1_10_b chess_char_1_12_b chess_char_1_20_b chess_char_1_18_b chess_char_1_19_b chess_char_1_13_b chess_char_1_14_b chess_char_1_17_b chess_char_2_01_a chess_char_2_02_a chess_char_2_04_a chess_char_2_05_a token_10000_silent_healrb",
|
||||||
@@ -136,13 +136,13 @@
|
|||||||
"ticks": 3300,
|
"ticks": 3300,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 83,
|
"killed": 83,
|
||||||
"total": 85,
|
"total": 34,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 59,
|
"rngDraws": 59,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":83,"total":85,"perfect":false,"leaks":2,"leaksUncounted":0,"coins":14,"dmg":119766,"bossDmg":0,"heal":20323,"deaths":3,"layerGains":{"preciShip":3}}
|
"p1": {"killed":83,"total":34,"perfect":false,"leaks":2,"leaksUncounted":0,"coins":14,"dmg":119766,"bossDmg":0,"heal":20323,"deaths":3,"layerGains":{"preciShip":3}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_1_10_b",0,"uniequip_002_sunbr",2493,1,5,0,0,0,288,2,2],
|
["p1",1,"op","chess_char_1_10_b",0,"uniequip_002_sunbr",2493,1,5,0,0,0,288,2,2],
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
"hooks": [1,105,3300,579,579,660,773,241,86,90,20,19,0,15985,207,29,85,0,1,92,1,0,0],
|
"hooks": [1,105,3300,579,579,660,773,241,86,90,20,19,0,15985,207,29,85,0,1,92,1,0,0],
|
||||||
"events": {"atk":656,"bounty":7,"deploy":17,"die":90,"dmg":749,"engage":9,"fx":101,"heal":212,"layer":1,"skill":39,"spawn":101,"status":228},
|
"events": {"atk":656,"bounty":7,"deploy":17,"die":90,"dmg":749,"engage":9,"fx":101,"heal":212,"layer":1,"skill":39,"spawn":101,"status":228},
|
||||||
"fx": {"aoe":1,"blink":1,"displace":8,"ember":1,"explode":9,"garrison":1,"heal":2,"phase":1,"pulse":41,"reveal":1,"sleep":7,"summon":25,"undying":1,"zone":2},
|
"fx": {"aoe":1,"blink":1,"displace":8,"ember":1,"explode":9,"garrison":1,"heal":2,"phase":1,"pulse":41,"reveal":1,"sleep":7,"summon":25,"undying":1,"zone":2},
|
||||||
"snaps": "ffe12829 556d50e1 da7db459 81849f4b 4e232e69 eedbef98 c39ed31b ac57d325 1cfe2ef9 4afd989a 6671745c"
|
"snaps": "23086eb2 62198379 4013be03 54f09692 fac5503d 7def8563 16aff8bb 1c932c52 15f57f1a 500e8654 2f228080"
|
||||||
},
|
},
|
||||||
"roster-004": {
|
"roster-004": {
|
||||||
"about": "pass 0 band 2: chess_char_2_08_a chess_char_2_17_a chess_char_2_18_a chess_char_2_07_a chess_char_2_09_a chess_char_2_11_a chess_char_2_13_a chess_char_2_16_a chess_char_2_12_a chess_char_2_06_a chess_char_2_10_a chess_char_2_14_a",
|
"about": "pass 0 band 2: chess_char_2_08_a chess_char_2_17_a chess_char_2_18_a chess_char_2_07_a chess_char_2_09_a chess_char_2_11_a chess_char_2_13_a chess_char_2_16_a chess_char_2_12_a chess_char_2_06_a chess_char_2_10_a chess_char_2_14_a",
|
||||||
@@ -386,12 +386,12 @@
|
|||||||
"ticks": 4200,
|
"ticks": 4200,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 110,
|
"killed": 110,
|
||||||
"total": 163,
|
"total": 71,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 63,
|
"rngDraws": 63,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":110,"total":163,"perfect":false,"leaks":53,"leaksUncounted":1,"coins":4,"dmg":209618,"bossDmg":0,"heal":1552,"deaths":11,"layerGains":{}}},
|
"players": {"p1":{"killed":110,"total":71,"perfect":false,"leaks":53,"leaksUncounted":1,"coins":4,"dmg":209618,"bossDmg":0,"heal":1552,"deaths":11,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_3_12_a",2,null,2334,1,185,2,2951,2334,989,14,1],
|
["p1",1,"op","chess_char_3_12_a",2,null,2334,1,185,2,2951,2334,989,14,1],
|
||||||
["p1",2,"op","chess_char_3_16_a",1,null,2173,1,189,0,455,3725,564,1,1],
|
["p1",2,"op","chess_char_3_16_a",1,null,2173,1,189,0,455,3725,564,1,1],
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
"hooks": [1,189,4200,602,602,858,862,392,122,176,30,29,0,18633,16,36,164,53,1,129,0,0,0],
|
"hooks": [1,189,4200,602,602,858,862,392,122,176,30,29,0,18633,16,36,164,53,1,129,0,0,0],
|
||||||
"events": {"atk":734,"bounty":2,"deploy":23,"die":123,"dmg":852,"engage":14,"fx":133,"heal":2,"leak":53,"skill":59,"spawn":180,"status":55},
|
"events": {"atk":734,"bounty":2,"deploy":23,"die":123,"dmg":852,"engage":14,"fx":133,"heal":2,"leak":53,"skill":59,"spawn":180,"status":55},
|
||||||
"fx": {"aoe":2,"camouflage":1,"displace":22,"dp":2,"explode":21,"phase":1,"pull":22,"revive":2,"sp":1,"strike":3,"summon":44,"volley":7,"wolfShadow":2,"wolfShadowLost":3},
|
"fx": {"aoe":2,"camouflage":1,"displace":22,"dp":2,"explode":21,"phase":1,"pull":22,"revive":2,"sp":1,"strike":3,"summon":44,"volley":7,"wolfShadow":2,"wolfShadowLost":3},
|
||||||
"snaps": "b9af8173 9dbbce46 0545bdc2 a248c945 96d564d2 dfdab949 5c15b625 9b9cfc78 8c803cc4 a8a11123 6ce4d37a 806b7f61 023220d2 bccf5abb"
|
"snaps": "8447aca6 0085b7c9 33307063 cd2bf975 e6c8eeb1 7331d165 340230f4 4e983e4a fd8f005c e400c6cd 0e8b1aa8 9330488b d2efc4c0 40e02a59"
|
||||||
},
|
},
|
||||||
"roster-008": {
|
"roster-008": {
|
||||||
"about": "pass 0 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b",
|
"about": "pass 0 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b",
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
"ticks": 5100,
|
"ticks": 5100,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 45,
|
"killed": 45,
|
||||||
"total": 87,
|
"total": 83,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 17,
|
"rngDraws": 17,
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 45,
|
"killed": 45,
|
||||||
"total": 87,
|
"total": 83,
|
||||||
"perfect": false,
|
"perfect": false,
|
||||||
"leaks": 42,
|
"leaks": 42,
|
||||||
"leaksUncounted": 1,
|
"leaksUncounted": 1,
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
"hooks": [1,137,5100,539,539,1062,1157,387,60,130,21,23,0,8443,80,45,88,39,1,66,12,12,0],
|
"hooks": [1,137,5100,539,539,1062,1157,387,60,130,21,23,0,8443,80,45,88,39,1,66,12,12,0],
|
||||||
"events": {"atk":624,"bounty":4,"deploy":49,"die":91,"dmg":1084,"engage":12,"fx":231,"heal":386,"layer":12,"leak":39,"skill":47,"spawn":128,"status":130},
|
"events": {"atk":624,"bounty":4,"deploy":49,"die":91,"dmg":1084,"engage":12,"fx":231,"heal":386,"layer":12,"leak":39,"skill":47,"spawn":128,"status":130},
|
||||||
"fx": {"aoe":20,"blink":3,"buff":1,"burst":12,"coin":29,"displace":32,"explode":34,"garrison":8,"layer":4,"pull":32,"revive":6,"shield":1,"shieldBreak":1,"strike":9,"summon":29,"telegraph":1,"volley":9},
|
"fx": {"aoe":20,"blink":3,"buff":1,"burst":12,"coin":29,"displace":32,"explode":34,"garrison":8,"layer":4,"pull":32,"revive":6,"shield":1,"shieldBreak":1,"strike":9,"summon":29,"telegraph":1,"volley":9},
|
||||||
"snaps": "17903402 6c8167ba 07381013 82bb886b 608b7ec4 249d7653 0d98c4e2 486693a5 84927b5d 4ec21382 2bb263b6 816b9d4f 845b7cfc 1702154b d531c7dc 0cff75b4 79d1c073"
|
"snaps": "17903402 6c8167ba 07381013 82bb886b 608b7ec4 49a9f161 e85b61cf 22016225 ee0925e6 4ee5eeb2 8a500100 aa9abb07 2ce0b42a fcb5b9cb d8b6506e b8cd651a b26cce51"
|
||||||
},
|
},
|
||||||
"roster-009": {
|
"roster-009": {
|
||||||
"about": "pass 0 band 4: chess_char_3_16_b chess_char_4_01_a chess_char_3_18_b chess_char_4_04_a chess_char_3_13_b chess_char_3_14_b chess_char_3_17_b chess_char_3_19_b chess_char_3_20_b chess_char_3_21_b chess_char_4_02_a chess_char_4_06_a token_10028_vigil_wolf",
|
"about": "pass 0 band 4: chess_char_3_16_b chess_char_4_01_a chess_char_3_18_b chess_char_4_04_a chess_char_3_13_b chess_char_3_14_b chess_char_3_17_b chess_char_3_19_b chess_char_3_20_b chess_char_3_21_b chess_char_4_02_a chess_char_4_06_a token_10028_vigil_wolf",
|
||||||
@@ -545,12 +545,12 @@
|
|||||||
"ticks": 4674,
|
"ticks": 4674,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 171,
|
"killed": 171,
|
||||||
"total": 171,
|
"total": 63,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 148,
|
"rngDraws": 148,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":171,"total":171,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":12,"dmg":279620,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
|
"players": {"p1":{"killed":171,"total":63,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":12,"dmg":279620,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_3_16_b",1,"uniequip_002_snakek",5363,1,3,0,0,0,0,0,0],
|
["p1",1,"op","chess_char_3_16_b",1,"uniequip_002_snakek",5363,1,3,0,0,0,0,0,0],
|
||||||
["p1",2,"op","chess_char_4_01_a",2,null,4563.74,1,6,0,0,0,0,0,0],
|
["p1",2,"op","chess_char_4_01_a",2,null,4563.74,1,6,0,0,0,0,0,0],
|
||||||
@@ -591,7 +591,7 @@
|
|||||||
"hooks": [1,187,4674,713,713,1306,1290,0,172,172,63,61,0,21782,106,10,171,0,1,179,0,0,0],
|
"hooks": [1,187,4674,713,713,1306,1290,0,172,172,63,61,0,21782,106,10,171,0,1,179,0,0,0],
|
||||||
"events": {"atk":886,"bounty":6,"deploy":14,"die":172,"dmg":1290,"engage":11,"fx":176,"skill":124,"spawn":186,"status":238},
|
"events": {"atk":886,"bounty":6,"deploy":14,"die":172,"dmg":1290,"engage":11,"fx":176,"skill":124,"spawn":186,"status":238},
|
||||||
"fx": {"camouflage":1,"crit":24,"displace":30,"dp":5,"explode":4,"phase":1,"ripple":8,"shadowWeave":24,"shield":2,"stealth":4,"summon":51,"telegraph":6,"volley":8,"wolfShadow":4,"wolfShadowLost":3,"zone":1},
|
"fx": {"camouflage":1,"crit":24,"displace":30,"dp":5,"explode":4,"phase":1,"ripple":8,"shadowWeave":24,"shield":2,"stealth":4,"summon":51,"telegraph":6,"volley":8,"wolfShadow":4,"wolfShadowLost":3,"zone":1},
|
||||||
"snaps": "aea843d4 76d68647 dede7c8e 5e9b6d5c 2a73c288 94334408 2debb788 3158593d c2e8e21e 178efcca 33143e68 c91245db 8d2f8f1e 0134b665 955f8917 a00328a1"
|
"snaps": "69a7f9d4 a676cad6 57a90446 c208123b ae74e9b8 1b323369 8b5283e9 4c70fd14 717d3193 f69fe3aa 1e6c1532 ddb7d885 f774a464 57e74ab4 20bd8a39 ac5fa54d"
|
||||||
},
|
},
|
||||||
"roster-010": {
|
"roster-010": {
|
||||||
"about": "pass 0 band 4: chess_char_4_17_a chess_char_4_18_a chess_char_4_11_a chess_char_4_19_a chess_char_4_07_a chess_char_4_16_a chess_char_4_09_a chess_char_4_10_a chess_char_4_12_a chess_char_4_13_a chess_char_4_14_a chess_char_4_20_a token_10041_cathy_catsld token_10041_cathy_catsld",
|
"about": "pass 0 band 4: chess_char_4_17_a chess_char_4_18_a chess_char_4_11_a chess_char_4_19_a chess_char_4_07_a chess_char_4_16_a chess_char_4_09_a chess_char_4_10_a chess_char_4_12_a chess_char_4_13_a chess_char_4_14_a chess_char_4_20_a token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||||
@@ -606,13 +606,13 @@
|
|||||||
"ticks": 5100,
|
"ticks": 5100,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 45,
|
"killed": 45,
|
||||||
"total": 102,
|
"total": 69,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 99,
|
"rngDraws": 99,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":45,"total":102,"perfect":false,"leaks":57,"leaksUncounted":0,"coins":4,"dmg":442763,"bossDmg":0,"heal":3335,"deaths":23,"layerGains":{"preciShip":12}}
|
"p1": {"killed":45,"total":69,"perfect":false,"leaks":57,"leaksUncounted":0,"coins":4,"dmg":442763,"bossDmg":0,"heal":3335,"deaths":23,"layerGains":{"preciShip":12}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_17_a",1,null,0,0,5,0,3672,2849,0,7,0],
|
["p1",1,"op","chess_char_4_17_a",1,null,0,0,5,0,3672,2849,0,7,0],
|
||||||
@@ -654,7 +654,7 @@
|
|||||||
"hooks": [1,132,5100,457,457,1127,1077,332,71,119,25,26,0,8109,376,12,102,51,1,200,3,0,41],
|
"hooks": [1,132,5100,457,457,1127,1077,332,71,119,25,26,0,8109,376,12,102,51,1,200,3,0,41],
|
||||||
"events": {"atk":557,"bounty":2,"deploy":27,"die":68,"dmg":985,"engage":12,"fx":195,"heal":7,"layer":3,"leak":51,"skill":52,"spawn":119,"status":335},
|
"events": {"atk":557,"bounty":2,"deploy":27,"die":68,"dmg":985,"engage":12,"fx":195,"heal":7,"layer":3,"leak":51,"skill":52,"spawn":119,"status":335},
|
||||||
"fx": {"block":9,"catShield":2,"charge":1,"dilemma":1,"dodge":41,"explode":18,"explosion":2,"featherArrow":1,"flame":1,"frostNova":4,"garrison":3,"garrisonGrant":1,"overclock":1,"phase":1,"pull":1,"riposte":2,"rockslide":4,"shieldBreak":5,"slippery":1,"summon":11,"swordRain":46,"swordStorm":8,"telegraph":2,"thorns":16,"tornado":1,"tornadoPulse":5,"undying":7},
|
"fx": {"block":9,"catShield":2,"charge":1,"dilemma":1,"dodge":41,"explode":18,"explosion":2,"featherArrow":1,"flame":1,"frostNova":4,"garrison":3,"garrisonGrant":1,"overclock":1,"phase":1,"pull":1,"riposte":2,"rockslide":4,"shieldBreak":5,"slippery":1,"summon":11,"swordRain":46,"swordStorm":8,"telegraph":2,"thorns":16,"tornado":1,"tornadoPulse":5,"undying":7},
|
||||||
"snaps": "06e15d5c 9c6e5b81 2ecc1e6a 71fee850 b79ab0d2 457ad226 ff2b9874 4cc2db7e f0f9ed59 2f89b40a aad31065 cbcace62 0b121c2c 860ca274 3c5123e2 f265b649 88aa7607"
|
"snaps": "06e15d5c 9c6e5b81 2ecc1e6a 71fee850 b79ab0d2 3601a029 af7e3130 8290988c ef603826 41e4badb 232f36b3 92680cda 18b57768 3253586c 584ddab4 65fdbd7c 43bb928d"
|
||||||
},
|
},
|
||||||
"roster-011": {
|
"roster-011": {
|
||||||
"about": "pass 0 band 5: chess_char_4_23_a chess_char_4_01_b chess_char_4_22_a chess_char_4_04_b chess_char_4_07_b chess_char_4_09_b chess_char_4_21_a chess_char_4_24_a chess_char_4_25_a chess_char_4_26_a chess_char_4_02_b chess_char_4_06_b",
|
"about": "pass 0 band 5: chess_char_4_23_a chess_char_4_01_b chess_char_4_22_a chess_char_4_04_b chess_char_4_07_b chess_char_4_09_b chess_char_4_21_a chess_char_4_24_a chess_char_4_25_a chess_char_4_26_a chess_char_4_02_b chess_char_4_06_b",
|
||||||
@@ -669,13 +669,13 @@
|
|||||||
"ticks": 5700,
|
"ticks": 5700,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 78,
|
"killed": 78,
|
||||||
"total": 82,
|
"total": 78,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 231,
|
"rngDraws": 231,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":78,"total":82,"perfect":false,"leaks":4,"leaksUncounted":0,"coins":12,"dmg":698950,"bossDmg":0,"heal":15928,"deaths":5,"layerGains":{"visiShip":8}}
|
"p1": {"killed":78,"total":78,"perfect":false,"leaks":4,"leaksUncounted":0,"coins":12,"dmg":698950,"bossDmg":0,"heal":15928,"deaths":5,"layerGains":{"visiShip":8}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_23_a",2,null,4358,1,99,11,140344,15336,0,111,3],
|
["p1",1,"op","chess_char_4_23_a",2,null,4358,1,99,11,140344,15336,0,111,3],
|
||||||
@@ -714,7 +714,7 @@
|
|||||||
"hooks": [1,101,5700,972,972,2317,2716,1581,83,83,48,44,30,29691,498,34,82,0,1,84,2,0,1],
|
"hooks": [1,101,5700,972,972,2317,2716,1581,83,83,48,44,30,29691,498,34,82,0,1,84,2,0,1],
|
||||||
"events": {"atk":1788,"bounty":6,"deploy":17,"die":83,"dmg":2486,"engage":9,"fx":280,"heal":79,"layer":2,"skill":92,"spawn":96,"status":1157},
|
"events": {"atk":1788,"bounty":6,"deploy":17,"die":83,"dmg":2486,"engage":9,"fx":280,"heal":79,"layer":2,"skill":92,"spawn":96,"status":1157},
|
||||||
"fx": {"camouflage":2,"charge":17,"counter":30,"crit":38,"dilemma":6,"displace":7,"dodge":1,"explode":1,"garrison":2,"healField":2,"jungleSoul":3,"mote":26,"quadShot":1,"redistribute":30,"revive":1,"reweave":2,"ripple":15,"sandChains":5,"sermon":1,"shadowWeave":35,"shield":4,"slash":36,"stealth":6,"summon":1,"telegraph":6,"truesilver":2},
|
"fx": {"camouflage":2,"charge":17,"counter":30,"crit":38,"dilemma":6,"displace":7,"dodge":1,"explode":1,"garrison":2,"healField":2,"jungleSoul":3,"mote":26,"quadShot":1,"redistribute":30,"revive":1,"reweave":2,"ripple":15,"sandChains":5,"sermon":1,"shadowWeave":35,"shield":4,"slash":36,"stealth":6,"summon":1,"telegraph":6,"truesilver":2},
|
||||||
"snaps": "4ec6c58b 31b6e4f7 b3a1832d 1d3aef5f fadc09c0 7c30f46f 4b78bbe9 86d9c187 89912cdc 65c1ecc2 769a2481 b2b9d6bb 1247c905 a2a0f97e ad63e000 ffd73b97 41a6748a 0b473fca 661ca1b1"
|
"snaps": "4ec6c58b 31b6e4f7 b3a1832d 1d3aef5f a87a8d54 03884138 51cdef78 9ed2fd94 6dc7967a 5abbf904 1c8c11b7 1d22075e 459d6f35 c29e281e 36befd7b 59fdbfb6 ba6c43fa eafb4ef7 2ce67130"
|
||||||
},
|
},
|
||||||
"roster-012": {
|
"roster-012": {
|
||||||
"about": "pass 0 band 5: chess_char_4_17_b chess_char_4_18_b chess_char_4_11_b chess_char_4_19_b chess_char_4_22_b chess_char_4_16_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b chess_char_4_20_b chess_char_4_21_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
"about": "pass 0 band 5: chess_char_4_17_b chess_char_4_18_b chess_char_4_11_b chess_char_4_19_b chess_char_4_22_b chess_char_4_16_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b chess_char_4_20_b chess_char_4_21_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||||
@@ -729,13 +729,13 @@
|
|||||||
"ticks": 4847,
|
"ticks": 4847,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 99,
|
"killed": 99,
|
||||||
"total": 100,
|
"total": 96,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 175,
|
"rngDraws": 175,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":99,"total":100,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":10,"dmg":998389,"bossDmg":0,"heal":23063,"deaths":10,"layerGains":{"preciShip":8}}
|
"p1": {"killed":99,"total":96,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":10,"dmg":998389,"bossDmg":0,"heal":23063,"deaths":10,"layerGains":{"preciShip":8}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3473.8,1,7,0,296,39,0,19,0],
|
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3473.8,1,7,0,296,39,0,19,0],
|
||||||
@@ -777,7 +777,7 @@
|
|||||||
"hooks": [1,125,4847,485,485,1427,1424,821,110,113,83,82,0,17042,1514,29,100,1,1,119,1,0,3],
|
"hooks": [1,125,4847,485,485,1427,1424,821,110,113,83,82,0,17042,1514,29,100,1,1,119,1,0,3],
|
||||||
"events": {"atk":636,"bounty":5,"deploy":22,"die":112,"dmg":1415,"engage":10,"fx":275,"heal":212,"layer":1,"leak":1,"skill":167,"spawn":117,"status":1018},
|
"events": {"atk":636,"bounty":5,"deploy":22,"die":112,"dmg":1415,"engage":10,"fx":275,"heal":212,"layer":1,"leak":1,"skill":167,"spawn":117,"status":1018},
|
||||||
"fx": {"beam":6,"blink":2,"block":2,"displace":11,"dodge":3,"explode":6,"explosion":23,"featherArrow":4,"flame":3,"frostNova":35,"garrison":1,"garrisonGrant":1,"healField":2,"overclock":1,"palsy":1,"pull":2,"revive":4,"riposte":2,"rockslide":4,"shieldBreak":9,"slash":30,"summon":1,"swordRain":28,"swordStorm":6,"telegraph":3,"thorns":1,"tornado":2,"tornadoPulse":10,"truesilver":3,"zone":69},
|
"fx": {"beam":6,"blink":2,"block":2,"displace":11,"dodge":3,"explode":6,"explosion":23,"featherArrow":4,"flame":3,"frostNova":35,"garrison":1,"garrisonGrant":1,"healField":2,"overclock":1,"palsy":1,"pull":2,"revive":4,"riposte":2,"rockslide":4,"shieldBreak":9,"slash":30,"summon":1,"swordRain":28,"swordStorm":6,"telegraph":3,"thorns":1,"tornado":2,"tornadoPulse":10,"truesilver":3,"zone":69},
|
||||||
"snaps": "ccfbde35 4d2aba8a 8dfe85b0 528967cd 3dbb0c86 c73d7931 d025c97a c9dab1f0 ecebc7c3 77ac09b9 fe0c30b6 3bcdfc52 7d4f4208 bdb5d1fa 39999c60 d73cc178 9d069a0a"
|
"snaps": "ccfbde35 4d2aba8a 8dfe85b0 528967cd 3dbb0c86 c06ed10c 2e96bd48 7b9b35a5 b7b36032 323ec922 4199cd1a 0940cade b87b745a ff75d11f 64cca2ce b9bb5bac fc4d294c"
|
||||||
},
|
},
|
||||||
"roster-013": {
|
"roster-013": {
|
||||||
"about": "pass 0 band 5: chess_char_4_23_b chess_char_5_08_a chess_char_5_01_a chess_char_5_05_a chess_char_5_06_a chess_char_5_07_a chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b chess_char_5_02_a chess_char_5_03_a chess_char_5_10_a",
|
"about": "pass 0 band 5: chess_char_4_23_b chess_char_5_08_a chess_char_5_01_a chess_char_5_05_a chess_char_5_06_a chess_char_5_07_a chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b chess_char_5_02_a chess_char_5_03_a chess_char_5_10_a",
|
||||||
@@ -792,7 +792,7 @@
|
|||||||
"ticks": 5645,
|
"ticks": 5645,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 99,
|
"killed": 99,
|
||||||
"total": 104,
|
"total": 102,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 53,
|
"rngDraws": 53,
|
||||||
@@ -800,7 +800,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 99,
|
"killed": 99,
|
||||||
"total": 104,
|
"total": 102,
|
||||||
"perfect": false,
|
"perfect": false,
|
||||||
"leaks": 5,
|
"leaks": 5,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -848,7 +848,7 @@
|
|||||||
"hooks": [1,124,5645,628,628,1890,2641,19730,108,114,26,25,18,27889,1089,33,110,5,1,163,29,2,0],
|
"hooks": [1,124,5645,628,628,1890,2641,19730,108,114,26,25,18,27889,1089,33,110,5,1,163,29,2,0],
|
||||||
"events": {"atk":1099,"bounty":5,"deploy":14,"die":109,"dmg":2218,"engage":8,"fx":297,"heal":65,"layer":29,"leak":5,"skill":51,"spawn":122,"status":801},
|
"events": {"atk":1099,"bounty":5,"deploy":14,"die":109,"dmg":2218,"engage":8,"fx":297,"heal":65,"layer":29,"leak":5,"skill":51,"spawn":122,"status":801},
|
||||||
"fx": {"anchor":3,"aoe":1,"bandage":2,"beam":3,"burst":2,"candle":6,"downed":1,"ember":1,"explode":1,"garrison":29,"garrisonGrant":1,"jungleSoul":2,"mote":96,"overload":2,"redistribute":40,"revive":1,"reweave":3,"sandChains":3,"sandChainsCharged":1,"spGift":44,"splash":18,"summon":1,"telegraph":1,"wake":35},
|
"fx": {"anchor":3,"aoe":1,"bandage":2,"beam":3,"burst":2,"candle":6,"downed":1,"ember":1,"explode":1,"garrison":29,"garrisonGrant":1,"jungleSoul":2,"mote":96,"overload":2,"redistribute":40,"revive":1,"reweave":3,"sandChains":3,"sandChainsCharged":1,"spGift":44,"splash":18,"summon":1,"telegraph":1,"wake":35},
|
||||||
"snaps": "14f005fc 39387feb a7349b84 20afb02c a4a1bc3c b2ee7185 6c44cc59 e221af55 b857607b 43c6569b 5c18ca91 7353ffa7 c2202075 ba12516c 0b0ebd92 57f918d9 fc6e30e4 df0b767f 1f363bf1"
|
"snaps": "14f005fc 39387feb a7349b84 20afb02c d0ced437 9221ce4b f3059442 d779613c b02261a1 aa86888c 027f54e2 c5539bb8 ca44f291 62ae4698 8893fcdd c4700cc1 6fe6886f 682dbfd2 35a8b2dc"
|
||||||
},
|
},
|
||||||
"roster-014": {
|
"roster-014": {
|
||||||
"about": "pass 0 band 6: chess_char_5_11_a chess_char_5_19_a chess_char_5_13_a chess_char_5_14_a chess_char_5_01_b chess_char_5_17_a chess_char_5_12_a chess_char_5_15_a chess_char_5_20_a chess_char_5_21_a chess_char_5_22_a chess_char_5_23_a",
|
"about": "pass 0 band 6: chess_char_5_11_a chess_char_5_19_a chess_char_5_13_a chess_char_5_14_a chess_char_5_01_b chess_char_5_17_a chess_char_5_12_a chess_char_5_15_a chess_char_5_20_a chess_char_5_21_a chess_char_5_22_a chess_char_5_23_a",
|
||||||
@@ -936,7 +936,7 @@
|
|||||||
"ticks": 5425,
|
"ticks": 5425,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 107,
|
"killed": 107,
|
||||||
"total": 107,
|
"total": 105,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 200,
|
"rngDraws": 200,
|
||||||
@@ -944,7 +944,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 107,
|
"killed": 107,
|
||||||
"total": 107,
|
"total": 105,
|
||||||
"perfect": true,
|
"perfect": true,
|
||||||
"leaks": 0,
|
"leaks": 0,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -1002,7 +1002,7 @@
|
|||||||
"hooks": [1,142,5425,343,343,2083,2705,4099,110,120,153,151,0,15568,4219,52,110,0,1,225,2,0,0],
|
"hooks": [1,142,5425,343,343,2083,2705,4099,110,120,153,151,0,15568,4219,52,110,0,1,225,2,0,0],
|
||||||
"events": {"atk":402,"bounty":6,"deploy":29,"die":120,"dmg":2098,"engage":10,"fx":465,"heal":1553,"layer":2,"skill":304,"spawn":131,"status":720},
|
"events": {"atk":402,"bounty":6,"deploy":29,"die":120,"dmg":2098,"engage":10,"fx":465,"heal":1553,"layer":2,"skill":304,"spawn":131,"status":720},
|
||||||
"fx": {"anchor":6,"aoe":45,"candle":3,"ember":2,"garrison":2,"healAoe":79,"overload":4,"revive":1,"sp":101,"substitute":1,"summon":3,"swap":1,"telegraph":1,"teleport":9,"zone":207},
|
"fx": {"anchor":6,"aoe":45,"candle":3,"ember":2,"garrison":2,"healAoe":79,"overload":4,"revive":1,"sp":101,"substitute":1,"summon":3,"swap":1,"telegraph":1,"teleport":9,"zone":207},
|
||||||
"snaps": "22cf0582 8a0c0b85 516b60a1 84aa4163 e05892d0 18489b1b 648324df 80fac9f4 376b8bee 85dbe4cb e627378e d788a444 4b8ef6d8 e2cf34c7 e37bfa3f c7e3226a 4c5d887f 61f8d5f0"
|
"snaps": "22cf0582 8a0c0b85 516b60a1 c00ea058 dc8d82f9 9ea7f69d 76fe7830 9f220f9d 40bd8a20 94e08671 39d32dd9 963505f6 3e70ec19 de5a5755 6b3cf9e2 fe38d43e ecb66d06 6bd2841f"
|
||||||
},
|
},
|
||||||
"roster-016": {
|
"roster-016": {
|
||||||
"about": "pass 0 band 6: chess_char_5_19_b chess_char_6_03_a chess_char_6_06_a chess_char_5_17_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_6_01_a chess_char_6_02_a chess_char_6_04_a chess_char_6_05_a token_10017_skadi2_dedant",
|
"about": "pass 0 band 6: chess_char_5_19_b chess_char_6_03_a chess_char_6_06_a chess_char_5_17_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_6_01_a chess_char_6_02_a chess_char_6_04_a chess_char_6_05_a token_10017_skadi2_dedant",
|
||||||
@@ -1078,7 +1078,7 @@
|
|||||||
"ticks": 5965,
|
"ticks": 5965,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 174,
|
"killed": 174,
|
||||||
"total": 174,
|
"total": 171,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 384,
|
"rngDraws": 384,
|
||||||
@@ -1086,7 +1086,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 174,
|
"killed": 174,
|
||||||
"total": 174,
|
"total": 171,
|
||||||
"perfect": true,
|
"perfect": true,
|
||||||
"leaks": 0,
|
"leaks": 0,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -1135,7 +1135,7 @@
|
|||||||
"hooks": [1,193,5965,895,895,1366,1742,349,184,186,46,43,123,35603,1303,179,174,0,1,193,3,3,0],
|
"hooks": [1,193,5965,895,895,1366,1742,349,184,186,46,43,123,35603,1303,179,174,0,1,193,3,3,0],
|
||||||
"events": {"atk":910,"bounty":6,"deploy":19,"die":186,"dmg":1518,"engage":11,"fx":201,"heal":138,"layer":3,"skill":89,"spawn":188,"status":918},
|
"events": {"atk":910,"bounty":6,"deploy":19,"die":186,"dmg":1518,"engage":11,"fx":201,"heal":138,"layer":3,"skill":89,"spawn":188,"status":918},
|
||||||
"fx": {"airstrike":33,"beam":6,"burst":3,"cleanse":3,"displace":1,"dp":3,"drone":50,"droneLock":21,"drones":1,"emergency":7,"explode":16,"finale":4,"garrison":3,"link":2,"scorchBurst":18,"slash":17,"steal":3,"summon":3,"sunBurst":2,"talent":3,"undying":2},
|
"fx": {"airstrike":33,"beam":6,"burst":3,"cleanse":3,"displace":1,"dp":3,"drone":50,"droneLock":21,"drones":1,"emergency":7,"explode":16,"finale":4,"garrison":3,"link":2,"scorchBurst":18,"slash":17,"steal":3,"summon":3,"sunBurst":2,"talent":3,"undying":2},
|
||||||
"snaps": "43b03df4 4fcc619d 2be2121d 28e92f35 ef487f7c 940fa13a 63b8128f 2dbfde08 4bbc92c9 5ff16546 090219f9 b0cab1a7 1d90c054 357d876a f60e8ae5 ae2c1c51 a715ad2a 4c2e4416 9b88218f 226d981f"
|
"snaps": "43b03df4 4fcc619d 2be2121d b478fa2a 1c29c601 b33d14b7 0584e2d0 82b225f7 09241fe7 4f2805dd 3462bfac 3ffdbd77 73f9a044 2a84c2d8 8d905cc5 3ebb29ff 02a7318c 9bab956c 7db8bfe9 5cc46ec7"
|
||||||
},
|
},
|
||||||
"roster-018": {
|
"roster-018": {
|
||||||
"about": "pass 0 band 7: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b chess_char_6_12_b token_10017_skadi2_dedant token_10030_mlyss_wtrman",
|
"about": "pass 0 band 7: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b chess_char_6_12_b token_10017_skadi2_dedant token_10030_mlyss_wtrman",
|
||||||
@@ -1150,7 +1150,7 @@
|
|||||||
"ticks": 5849,
|
"ticks": 5849,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 120,
|
"killed": 120,
|
||||||
"total": 120,
|
"total": 117,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 483,
|
"rngDraws": 483,
|
||||||
@@ -1158,7 +1158,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 120,
|
"killed": 120,
|
||||||
"total": 120,
|
"total": 117,
|
||||||
"perfect": true,
|
"perfect": true,
|
||||||
"leaks": 0,
|
"leaks": 0,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -1217,7 +1217,7 @@
|
|||||||
"hooks": [1,145,5849,513,513,1336,1487,3519,124,130,34,30,37,24659,381,5,122,0,1,124,11,6,0],
|
"hooks": [1,145,5849,513,513,1336,1487,3519,124,130,34,30,37,24659,381,5,122,0,1,124,11,6,0],
|
||||||
"events": {"atk":606,"bounty":6,"deploy":21,"die":130,"dmg":1407,"engage":12,"fx":260,"heal":185,"layer":11,"skill":64,"spawn":145,"status":385},
|
"events": {"atk":606,"bounty":6,"deploy":21,"die":130,"dmg":1407,"engage":12,"fx":260,"heal":185,"layer":11,"skill":64,"spawn":145,"status":385},
|
||||||
"fx": {"beam":4,"bombard":31,"bombardShell":31,"burst":6,"camouflage":2,"copy":1,"dp":4,"explode":1,"garrison":8,"lightning":87,"link":1,"lock":37,"lure":2,"scorchBurst":17,"snow":7,"split":4,"storm":7,"summon":2,"talent":1,"tide":4,"wanted":3},
|
"fx": {"beam":4,"bombard":31,"bombardShell":31,"burst":6,"camouflage":2,"copy":1,"dp":4,"explode":1,"garrison":8,"lightning":87,"link":1,"lock":37,"lure":2,"scorchBurst":17,"snow":7,"split":4,"storm":7,"summon":2,"talent":1,"tide":4,"wanted":3},
|
||||||
"snaps": "19eb7843 2593e6ad ced96303 5ffc34ca 8cbd7231 1e7d8197 948c256c bd58965c b2457d86 151be715 19fcaae7 098875f8 0e3baca8 3c739f96 4210ffa1 2d576f76 35e8b3e6 e853bd87 a170c428 32d5be0d"
|
"snaps": "19eb7843 2593e6ad ced96303 9384a4db 45274713 75c80112 cd1ef1a1 6ff0d830 14dbf244 b55f9101 060019d3 8b4d624b 170d8186 4e4fbe70 f9844c3a d8a1becb eec26a32 99466fc4 2cf9d317 4ac77e1f"
|
||||||
},
|
},
|
||||||
"roster-019": {
|
"roster-019": {
|
||||||
"about": "pass 0 band 7: chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_6_17_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b",
|
"about": "pass 0 band 7: chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_6_17_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b",
|
||||||
@@ -1303,12 +1303,12 @@
|
|||||||
"ticks": 2700,
|
"ticks": 2700,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 30,
|
"killed": 30,
|
||||||
"total": 43,
|
"total": 38,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 93,
|
"rngDraws": 93,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":30,"total":43,"perfect":false,"leaks":13,"leaksUncounted":0,"coins":8,"dmg":169863,"bossDmg":0,"heal":20420,"deaths":6,"layerGains":{}}},
|
"players": {"p1":{"killed":30,"total":38,"perfect":false,"leaks":13,"leaksUncounted":0,"coins":8,"dmg":169863,"bossDmg":0,"heal":20420,"deaths":6,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_1_02_a",0,null,6437.42,1,3,0,1704,1216,282,11,1],
|
["p1",1,"op","chess_char_1_02_a",0,null,6437.42,1,3,0,1704,1216,282,11,1],
|
||||||
["p1",2,"op","chess_char_1_04_a",0,null,4318.46,1,5,3,8847,1618,0,36,1],
|
["p1",2,"op","chess_char_1_04_a",0,null,4318.46,1,5,3,8847,1618,0,36,1],
|
||||||
@@ -1341,7 +1341,7 @@
|
|||||||
"hooks": [1,57,2700,527,526,673,721,5609,36,39,26,24,6,12672,50,35,43,3,1,36,0,3,0],
|
"hooks": [1,57,2700,527,526,673,721,5609,36,39,26,24,6,12672,50,35,43,3,1,36,0,3,0],
|
||||||
"events": {"atk":597,"bounty":4,"deploy":12,"die":36,"dmg":703,"engage":12,"fx":48,"heal":53,"leak":3,"skill":50,"spawn":57,"status":149},
|
"events": {"atk":597,"bounty":4,"deploy":12,"die":36,"dmg":703,"engage":12,"fx":48,"heal":53,"leak":3,"skill":50,"spawn":57,"status":149},
|
||||||
"fx": {"buff":1,"burst":3,"crit":28,"dp":2,"explode":10,"summon":2,"telegraph":2},
|
"fx": {"buff":1,"burst":3,"crit":28,"dp":2,"explode":10,"summon":2,"telegraph":2},
|
||||||
"snaps": "7569fa79 a7c3130a 6e861c2b f81be7ba 7f7d11f4 b000e94a a637c5f7 adaab972 f533cb58"
|
"snaps": "7569fa79 a7c3130a 6e861c2b f81be7ba 7f7d11f4 1838edf3 c81da2a1 0cddd171 e575a9d9"
|
||||||
},
|
},
|
||||||
"roster-021": {
|
"roster-021": {
|
||||||
"about": "pass 1 band 2: chess_char_1_02_b chess_char_1_20_a chess_char_1_04_b chess_char_1_08_b chess_char_1_18_a chess_char_1_19_a chess_char_1_17_a chess_char_1_01_b chess_char_1_03_b chess_char_1_06_b chess_char_1_07_b chess_char_1_09_b",
|
"about": "pass 1 band 2: chess_char_1_02_b chess_char_1_20_a chess_char_1_04_b chess_char_1_08_b chess_char_1_18_a chess_char_1_19_a chess_char_1_17_a chess_char_1_01_b chess_char_1_03_b chess_char_1_06_b chess_char_1_07_b chess_char_1_09_b",
|
||||||
@@ -1462,12 +1462,12 @@
|
|||||||
"ticks": 3300,
|
"ticks": 3300,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 33,
|
"killed": 33,
|
||||||
"total": 36,
|
"total": 35,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":33,"total":36,"perfect":false,"leaks":3,"leaksUncounted":0,"coins":6,"dmg":156300,"bossDmg":0,"heal":21166,"deaths":4,"layerGains":{}}},
|
"players": {"p1":{"killed":33,"total":35,"perfect":false,"leaks":3,"leaksUncounted":0,"coins":6,"dmg":156300,"bossDmg":0,"heal":21166,"deaths":4,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_2_08_a",0,null,1209.08,1,7,3,11496,6440,380,12,1],
|
["p1",1,"op","chess_char_2_08_a",0,null,1209.08,1,7,3,11496,6440,380,12,1],
|
||||||
["p1",2,"op","chess_char_2_17_a",0,null,0,0,11,5,14348,6625,2659,15,3],
|
["p1",2,"op","chess_char_2_17_a",0,null,0,0,11,5,14348,6625,2659,15,3],
|
||||||
@@ -1502,7 +1502,7 @@
|
|||||||
"hooks": [1,53,3300,397,397,859,960,3173,37,37,21,19,0,10920,67,46,36,0,1,37,0,3,0],
|
"hooks": [1,53,3300,397,397,859,960,3173,37,37,21,19,0,10920,67,46,36,0,1,37,0,3,0],
|
||||||
"events": {"atk":577,"bounty":3,"deploy":14,"die":37,"dmg":879,"engage":10,"fx":55,"heal":97,"skill":40,"spawn":51,"status":183},
|
"events": {"atk":577,"bounty":3,"deploy":14,"die":37,"dmg":879,"engage":10,"fx":55,"heal":97,"skill":40,"spawn":51,"status":183},
|
||||||
"fx": {"beam":42,"buff":3,"burst":3,"explode":1,"summon":1,"takeoff":1,"telegraph":4},
|
"fx": {"beam":42,"buff":3,"burst":3,"explode":1,"summon":1,"takeoff":1,"telegraph":4},
|
||||||
"snaps": "ac6bb3ba c0659ec1 ae5b2e72 9a4892cf dcee00e3 e6820472 6acace6e 585a9080 190d8eac c4627eb2 95fb3d5c"
|
"snaps": "ac6bb3ba c0659ec1 ae5b2e72 9a4892cf dcee00e3 e6820472 6acace6e 585a9080 190d8eac 52c080f3 2bc23cb2"
|
||||||
},
|
},
|
||||||
"roster-024": {
|
"roster-024": {
|
||||||
"about": "pass 1 band 3: chess_char_2_07_b chess_char_2_08_b chess_char_2_09_b chess_char_2_11_b chess_char_2_12_b chess_char_2_19_a chess_char_2_01_b chess_char_2_02_b chess_char_2_04_b chess_char_2_05_b chess_char_2_06_b chess_char_2_10_b",
|
"about": "pass 1 band 3: chess_char_2_07_b chess_char_2_08_b chess_char_2_09_b chess_char_2_11_b chess_char_2_12_b chess_char_2_19_a chess_char_2_01_b chess_char_2_02_b chess_char_2_04_b chess_char_2_05_b chess_char_2_06_b chess_char_2_10_b",
|
||||||
@@ -1517,12 +1517,12 @@
|
|||||||
"ticks": 4200,
|
"ticks": 4200,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 167,
|
"killed": 167,
|
||||||
"total": 193,
|
"total": 67,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 11,
|
"rngDraws": 11,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":167,"total":193,"perfect":false,"leaks":26,"leaksUncounted":0,"coins":6,"dmg":198262,"bossDmg":0,"heal":21090,"deaths":2,"layerGains":{}}},
|
"players": {"p1":{"killed":167,"total":67,"perfect":false,"leaks":26,"leaksUncounted":0,"coins":6,"dmg":198262,"bossDmg":0,"heal":21090,"deaths":2,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_2_07_b",0,"none",0,0,3,54,81792,7383,4670,60,2],
|
["p1",1,"op","chess_char_2_07_b",0,"none",0,0,3,54,81792,7383,4670,60,2],
|
||||||
["p1",2,"op","chess_char_2_08_b",0,"none",3193,1,1,0,0,0,0,0,0],
|
["p1",2,"op","chess_char_2_08_b",0,"none",3193,1,1,0,0,0,0,0,0],
|
||||||
@@ -1556,7 +1556,7 @@
|
|||||||
"hooks": [1,205,4200,764,764,1112,1178,2273,169,192,67,65,0,13967,1101,46,193,23,1,176,0,0,0],
|
"hooks": [1,205,4200,764,764,1112,1178,2273,169,192,67,65,0,13967,1101,46,193,23,1,176,0,0,0],
|
||||||
"events": {"atk":978,"bounty":3,"deploy":12,"die":169,"dmg":1114,"engage":8,"fx":79,"heal":146,"leak":23,"skill":132,"spawn":205,"status":533},
|
"events": {"atk":978,"bounty":3,"deploy":12,"die":169,"dmg":1114,"engage":8,"fx":79,"heal":146,"leak":23,"skill":132,"spawn":205,"status":533},
|
||||||
"fx": {"beam":3,"buff":5,"phase":1,"summon":58,"undying":1,"zone":11},
|
"fx": {"beam":3,"buff":5,"phase":1,"summon":58,"undying":1,"zone":11},
|
||||||
"snaps": "1fdc139e b67d37a5 27cdbfd2 91118bdf 5bc23ae1 dae0fe50 cdd53ba4 ff5c2f87 6658a90d 16089f28 0066b1b1 8273a045 1b52d504 45f8843e"
|
"snaps": "1fdc139e 5e66695d 23864921 b3d77d2d 9375efa4 7e27f254 3d501adb ccd8238b 93471fba 03de623d 3b85f08c 6b325e0e bfacea34 155ec05b"
|
||||||
},
|
},
|
||||||
"roster-025": {
|
"roster-025": {
|
||||||
"about": "pass 1 band 3: chess_char_2_17_b chess_char_2_18_b chess_char_3_06_a chess_char_2_13_b chess_char_2_16_b chess_char_3_02_a chess_char_3_03_a chess_char_3_04_a chess_char_3_05_a chess_char_2_14_b chess_char_2_19_b chess_char_3_01_a",
|
"about": "pass 1 band 3: chess_char_2_17_b chess_char_2_18_b chess_char_3_06_a chess_char_2_13_b chess_char_2_16_b chess_char_3_02_a chess_char_3_03_a chess_char_3_04_a chess_char_3_05_a chess_char_2_14_b chess_char_2_19_b chess_char_3_01_a",
|
||||||
@@ -1571,13 +1571,13 @@
|
|||||||
"ticks": 4200,
|
"ticks": 4200,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 65,
|
"killed": 65,
|
||||||
"total": 65,
|
"total": 64,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 154,
|
"rngDraws": 154,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":65,"total":65,"perfect":true,"leaks":0,"leaksUncounted":1,"coins":6,"dmg":412510,"bossDmg":0,"heal":30454,"deaths":0,"layerGains":{"steadShip":1}}
|
"p1": {"killed":65,"total":64,"perfect":true,"leaks":0,"leaksUncounted":1,"coins":6,"dmg":412510,"bossDmg":0,"heal":30454,"deaths":0,"layerGains":{"steadShip":1}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_2_17_b",0,"none",12144.76,1,4,0,0,4149,4149,0,6],
|
["p1",1,"op","chess_char_2_17_b",0,"none",12144.76,1,4,0,0,4149,4149,0,6],
|
||||||
@@ -1612,7 +1612,7 @@
|
|||||||
"hooks": [1,81,4200,876,876,1291,1295,8512,65,65,136,135,0,8514,337,96,66,0,1,123,1,0,0],
|
"hooks": [1,81,4200,876,876,1291,1295,8512,65,65,136,135,0,8514,337,96,66,0,1,123,1,0,0],
|
||||||
"events": {"atk":929,"bounty":3,"deploy":12,"die":65,"dmg":1295,"engage":8,"fx":177,"heal":230,"layer":1,"skill":273,"spawn":81,"status":249},
|
"events": {"atk":929,"bounty":3,"deploy":12,"die":65,"dmg":1295,"engage":8,"fx":177,"heal":230,"layer":1,"skill":273,"spawn":81,"status":249},
|
||||||
"fx": {"buff":1,"coin":2,"ember":58,"garrison":1,"revive":1,"sp":95,"summon":1,"takeoff":1,"zone":17},
|
"fx": {"buff":1,"coin":2,"ember":58,"garrison":1,"revive":1,"sp":95,"summon":1,"takeoff":1,"zone":17},
|
||||||
"snaps": "93455703 0f041837 3bae9724 5e0316f5 8ed5e397 724be440 817d612f 08b77e5e 84166039 862c8938 f534d19b d3c927ca de1e8a39 a7c4f443"
|
"snaps": "93455703 0f041837 3bae9724 5e0316f5 8ed5e397 724be440 6dbe253b d1c21d2a cf2cc6da feba90e7 fd2030a8 5e9f571d 0b4e33a8 06fbbefa"
|
||||||
},
|
},
|
||||||
"roster-026": {
|
"roster-026": {
|
||||||
"about": "pass 1 band 3: chess_char_3_12_a chess_char_3_16_a chess_char_3_18_a chess_char_3_08_a chess_char_3_09_a chess_char_3_10_a chess_char_3_11_a chess_char_3_13_a chess_char_3_14_a chess_char_3_17_a chess_char_3_19_a chess_char_3_20_a token_10028_vigil_wolf",
|
"about": "pass 1 band 3: chess_char_3_12_a chess_char_3_16_a chess_char_3_18_a chess_char_3_08_a chess_char_3_09_a chess_char_3_10_a chess_char_3_11_a chess_char_3_13_a chess_char_3_14_a chess_char_3_17_a chess_char_3_19_a chess_char_3_20_a token_10028_vigil_wolf",
|
||||||
@@ -1627,12 +1627,12 @@
|
|||||||
"ticks": 4200,
|
"ticks": 4200,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 18,
|
"killed": 18,
|
||||||
"total": 73,
|
"total": 71,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 164,
|
"rngDraws": 164,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":18,"total":73,"perfect":false,"leaks":55,"leaksUncounted":0,"coins":6,"dmg":183879,"bossDmg":0,"heal":1883,"deaths":21,"layerGains":{}}},
|
"players": {"p1":{"killed":18,"total":71,"perfect":false,"leaks":55,"leaksUncounted":0,"coins":6,"dmg":183879,"bossDmg":0,"heal":1883,"deaths":21,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_3_12_a",0,null,0,0,96,1,7787,4668,1883,19,4],
|
["p1",1,"op","chess_char_3_12_a",0,null,0,0,96,1,7787,4668,1883,19,4],
|
||||||
["p1",2,"op","chess_char_3_16_a",0,null,0,0,6,1,3499,2173,0,12,1],
|
["p1",2,"op","chess_char_3_16_a",0,null,0,0,6,1,3499,2173,0,12,1],
|
||||||
@@ -1669,7 +1669,7 @@
|
|||||||
"hooks": [1,102,4200,383,383,1447,1501,12,40,94,30,30,0,9243,22,42,73,53,1,43,0,0,0],
|
"hooks": [1,102,4200,383,383,1447,1501,12,40,94,30,30,0,9243,22,42,73,53,1,43,0,0,0],
|
||||||
"events": {"atk":436,"bounty":3,"deploy":26,"die":41,"dmg":1499,"engage":14,"fx":27,"heal":11,"leak":53,"skill":60,"spawn":90,"status":63},
|
"events": {"atk":436,"bounty":3,"deploy":26,"die":41,"dmg":1499,"engage":14,"fx":27,"heal":11,"leak":53,"skill":60,"spawn":90,"status":63},
|
||||||
"fx": {"dp":5,"explode":4,"liberate":1,"phase":1,"revive":4,"shield":2,"sp":1,"strike":1,"summon":2,"telegraph":3,"wolfShadow":1,"wolfShadowLost":2},
|
"fx": {"dp":5,"explode":4,"liberate":1,"phase":1,"revive":4,"shield":2,"sp":1,"strike":1,"summon":2,"telegraph":3,"wolfShadow":1,"wolfShadowLost":2},
|
||||||
"snaps": "1a7d12d1 7955f440 d99919a7 ba142976 2092d384 8dfb7d8e b96cb02d f9c0f4f2 731ec520 3fc5716e da654f48 c224a8bc e3c0b86b 51db902d"
|
"snaps": "1a7d12d1 7955f440 d99919a7 09818fe9 5d85594c eb4b0484 1591b58e c034a33b 2c27bf89 7a9f0e18 14dd1382 37e5dbf7 b8884791 2ad0053c"
|
||||||
},
|
},
|
||||||
"roster-027": {
|
"roster-027": {
|
||||||
"about": "pass 1 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b",
|
"about": "pass 1 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b",
|
||||||
@@ -1684,13 +1684,13 @@
|
|||||||
"ticks": 5100,
|
"ticks": 5100,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 41,
|
"killed": 41,
|
||||||
"total": 57,
|
"total": 53,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 143,
|
"rngDraws": 143,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {
|
"players": {
|
||||||
"p1": {"killed":41,"total":57,"perfect":false,"leaks":16,"leaksUncounted":0,"coins":8,"dmg":451319,"bossDmg":0,"heal":38831,"deaths":7,"layerGains":{"raidShip":18}}
|
"p1": {"killed":41,"total":53,"perfect":false,"leaks":16,"leaksUncounted":0,"coins":8,"dmg":451319,"bossDmg":0,"heal":38831,"deaths":7,"layerGains":{"raidShip":18}}
|
||||||
},
|
},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_3_06_b",0,"none",0,0,2,0,1394,6130,0,11,1],
|
["p1",1,"op","chess_char_3_06_b",0,"none",0,0,2,0,1394,6130,0,11,1],
|
||||||
@@ -1723,7 +1723,7 @@
|
|||||||
"hooks": [1,71,5100,765,765,2076,2076,69,48,64,151,151,0,10608,52,29,57,14,1,48,3,0,0],
|
"hooks": [1,71,5100,765,765,2076,2076,69,48,64,151,151,0,10608,52,29,57,14,1,48,3,0,0],
|
||||||
"events": {"atk":950,"bounty":4,"deploy":14,"die":50,"dmg":1979,"engage":12,"fx":118,"heal":69,"layer":3,"leak":14,"skill":305,"spawn":69,"status":185},
|
"events": {"atk":950,"bounty":4,"deploy":14,"die":50,"dmg":1979,"engage":12,"fx":118,"heal":69,"layer":3,"leak":14,"skill":305,"spawn":69,"status":185},
|
||||||
"fx": {"blink":1,"buff":1,"coin":32,"explode":15,"garrison":3,"shield":18,"shieldBreak":1,"strike":13,"summon":1,"volley":7,"zone":26},
|
"fx": {"blink":1,"buff":1,"coin":32,"explode":15,"garrison":3,"shield":18,"shieldBreak":1,"strike":13,"summon":1,"volley":7,"zone":26},
|
||||||
"snaps": "5cda3b22 5fca5f0c d70d8372 4fe579a4 2c9b6956 8d5ffb46 9aeb8795 69cfc5d4 e7047e1f 38960979 63e2a094 700bd4c4 0fc17381 24617d77 874750a0 4b37c245 395b3b9a"
|
"snaps": "5cda3b22 5fca5f0c d70d8372 4fe579a4 dac79dc9 4a2e282a 6a55c54b 86e02720 9144eaf2 1bf08368 f064ba8f 2fb3e530 9274187f a89b50de 49881a0a be34c52c 49de8118"
|
||||||
},
|
},
|
||||||
"roster-028": {
|
"roster-028": {
|
||||||
"about": "pass 1 band 4: chess_char_3_16_b chess_char_4_01_a chess_char_3_18_b chess_char_4_04_a chess_char_3_13_b chess_char_3_14_b chess_char_3_17_b chess_char_3_19_b chess_char_3_20_b chess_char_3_21_b chess_char_4_02_a chess_char_4_06_a token_10028_vigil_wolf",
|
"about": "pass 1 band 4: chess_char_3_16_b chess_char_4_01_a chess_char_3_18_b chess_char_4_04_a chess_char_3_13_b chess_char_3_14_b chess_char_3_17_b chess_char_3_19_b chess_char_3_20_b chess_char_3_21_b chess_char_4_02_a chess_char_4_06_a token_10028_vigil_wolf",
|
||||||
@@ -2268,7 +2268,7 @@
|
|||||||
"ticks": 6492,
|
"ticks": 6492,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 51,
|
"killed": 51,
|
||||||
"total": 131,
|
"total": 86,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 136,
|
"rngDraws": 136,
|
||||||
@@ -2276,7 +2276,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 51,
|
"killed": 51,
|
||||||
"total": 131,
|
"total": 86,
|
||||||
"perfect": false,
|
"perfect": false,
|
||||||
"leaks": 80,
|
"leaks": 80,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -2331,7 +2331,7 @@
|
|||||||
"hooks": [1,175,6492,504,504,2453,2631,28400,82,166,61,59,39,10872,1110,32,131,80,1,57,1,2,0],
|
"hooks": [1,175,6492,504,504,2453,2631,28400,82,166,61,59,39,10872,1110,32,131,80,1,57,1,2,0],
|
||||||
"events": {"atk":605,"bounty":2,"deploy":41,"die":86,"dmg":2534,"engage":15,"fx":82,"heal":20,"layer":1,"leak":80,"skill":120,"spawn":156,"status":656},
|
"events": {"atk":605,"bounty":2,"deploy":41,"die":86,"dmg":2534,"engage":15,"fx":82,"heal":20,"layer":1,"leak":80,"skill":120,"spawn":156,"status":656},
|
||||||
"fx": {"airstrike":11,"aoe":8,"burst":2,"copy":5,"explode":29,"garrison":1,"heal":4,"healField":2,"link":2,"revive":1,"split":1,"stone":1,"summon":15},
|
"fx": {"airstrike":11,"aoe":8,"burst":2,"copy":5,"explode":29,"garrison":1,"heal":4,"healField":2,"link":2,"revive":1,"split":1,"stone":1,"summon":15},
|
||||||
"snaps": "e7f57cb9 5d2f7fdf a34e9406 8a58323a 69de9406 d21d1321 1a7dd414 1453d4cf 75734598 fe883da1 9cea1cbf d7499dcf 74a67785 3dae277f 03326b45 4d301f69 b4e3db34 2f746db2 a0ce697f 30caa953 92e4bdb1 c3ddaeb8"
|
"snaps": "e7f57cb9 5d2f7fdf a34e9406 8a58323a 7d07bc24 abc407ce 319e7ebf 95f85fd9 9a3a7827 7b2bbf31 12b76db4 084b6d1d ed65c31c dbb67422 b1a1c69a 25e7a551 0c4ac123 51e0bc76 65d11431 1570d59d 03fbdd0e 985fd819"
|
||||||
},
|
},
|
||||||
"roster-038": {
|
"roster-038": {
|
||||||
"about": "pass 1 band 7: chess_char_5_08_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_5_06_b chess_char_6_17_b chess_char_5_07_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b",
|
"about": "pass 1 band 7: chess_char_5_08_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_5_06_b chess_char_6_17_b chess_char_5_07_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b",
|
||||||
@@ -2763,7 +2763,7 @@
|
|||||||
"ticks": 5511,
|
"ticks": 5511,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 68,
|
"killed": 68,
|
||||||
"total": 68,
|
"total": 67,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 38,
|
"rngDraws": 38,
|
||||||
@@ -2771,7 +2771,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 68,
|
"killed": 68,
|
||||||
"total": 68,
|
"total": 67,
|
||||||
"perfect": true,
|
"perfect": true,
|
||||||
"leaks": 0,
|
"leaks": 0,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -2822,7 +2822,7 @@
|
|||||||
"hooks": [1,91,5511,386,379,786,1232,8816,69,74,13,10,34,23943,622,17,68,0,1,69,4,52,0],
|
"hooks": [1,91,5511,386,379,786,1232,8816,69,74,13,10,34,23943,622,17,68,0,1,69,4,52,0],
|
||||||
"events": {"atk":446,"bounty":4,"deploy":20,"die":74,"dmg":1049,"engage":8,"fx":162,"layer":4,"skill":23,"spawn":90,"status":1246},
|
"events": {"atk":446,"bounty":4,"deploy":20,"die":74,"dmg":1049,"engage":8,"fx":162,"layer":4,"skill":23,"spawn":90,"status":1246},
|
||||||
"fx": {"airstrike":10,"anchor":1,"burst":52,"crit":14,"dp":3,"expose":6,"garrison":3,"link":2,"lock":14,"snow":5,"summon":2,"summonStun":2,"talent":1,"wanted":47},
|
"fx": {"airstrike":10,"anchor":1,"burst":52,"crit":14,"dp":3,"expose":6,"garrison":3,"link":2,"lock":14,"snow":5,"summon":2,"summonStun":2,"talent":1,"wanted":47},
|
||||||
"snaps": "120f2050 546489af 7dcae1cb ba57ea31 14949879 f5908ce9 602d5c12 63bba2cf a8e27e28 9ffc8315 03831118 1358bb3c 17c12939 59ead8d8 4c60897d 078878c9 827f7455 b7ae543f 912fccf8"
|
"snaps": "120f2050 546489af cf372130 f94ff0af c7481d33 d45316d4 4f6aa634 25182725 e35a21e0 eef0037b 837c3a5d a6636a5e 1e78c189 0b81163f 723d13fc 6d325c2f 4e7ece19 c7ca24b8 06924ce2"
|
||||||
},
|
},
|
||||||
"roster-046": {
|
"roster-046": {
|
||||||
"about": "pass 2 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b token_10017_skadi2_dedant",
|
"about": "pass 2 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b token_10017_skadi2_dedant",
|
||||||
@@ -2892,12 +2892,12 @@
|
|||||||
"ticks": 6312,
|
"ticks": 6312,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 66,
|
"killed": 66,
|
||||||
"total": 142,
|
"total": 139,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 257,
|
"rngDraws": 257,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":66,"total":142,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":6,"dmg":348541,"bossDmg":0,"heal":10968,"deaths":28,"layerGains":{}}},
|
"players": {"p1":{"killed":66,"total":139,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":6,"dmg":348541,"bossDmg":0,"heal":10968,"deaths":28,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_6_06_b",1,"none",0,0,5,11,42583,2639,0,22,2],
|
["p1",1,"op","chess_char_6_06_b",1,"none",0,0,5,11,42583,2639,0,22,2],
|
||||||
["p1",2,"op","chess_char_6_15_b",1,"none",0,0,151,4,39276,6912,0,29,2],
|
["p1",2,"op","chess_char_6_15_b",1,"none",0,0,151,4,39276,6912,0,29,2],
|
||||||
@@ -2937,7 +2937,7 @@
|
|||||||
"hooks": [1,182,6312,730,723,806,909,111,97,177,14,14,90,10310,718,53,142,76,1,97,0,0,0],
|
"hooks": [1,182,6312,730,723,806,909,111,97,177,14,14,90,10310,718,53,142,76,1,97,0,0,0],
|
||||||
"events": {"atk":737,"bounty":3,"deploy":37,"die":101,"dmg":824,"engage":12,"fx":52,"heal":19,"leak":76,"skill":28,"spawn":164,"status":153},
|
"events": {"atk":737,"bounty":3,"deploy":37,"die":101,"dmg":824,"engage":12,"fx":52,"heal":19,"leak":76,"skill":28,"spawn":164,"status":153},
|
||||||
"fx": {"airstrike":29,"anchor":2,"appear":1,"blink":1,"copy":1,"disappear":1,"explode":1,"garrisonGrant":2,"heal":2,"link":1,"revive":3,"summon":2,"summonStun":2,"sunBurst":1,"sword":2,"zone":1},
|
"fx": {"airstrike":29,"anchor":2,"appear":1,"blink":1,"copy":1,"disappear":1,"explode":1,"garrisonGrant":2,"heal":2,"link":1,"revive":3,"summon":2,"summonStun":2,"sunBurst":1,"sword":2,"zone":1},
|
||||||
"snaps": "3c9f691f 34faf375 087176f6 1c545a7a 0aa65801 59083ac8 08f660b0 d5292070 05ddc682 c1288138 21433f07 92e5a16c 7bfac0b9 0e1d7724 c53060df a263901a 9433d9ba 6046dcd9 3521e34a 4ddff910 6f17f8e1"
|
"snaps": "3c9f691f 34faf375 b7085c9e ad0ffeeb a5b75ffe d6e8b1d3 035b35db 66381fb1 b9877510 753ba510 04252066 ed355da1 5c82f27b 1be63ee2 55031d43 90c8bd6f b369a095 593b947c be089a07 29e3c756 28b150ee"
|
||||||
},
|
},
|
||||||
"roster-048": {
|
"roster-048": {
|
||||||
"about": "pass 2 band 7: chess_char_6_19_b chess_char_5_13_b chess_char_6_20_b chess_char_5_12_b chess_char_5_20_b chess_char_6_05_b",
|
"about": "pass 2 band 7: chess_char_6_19_b chess_char_5_13_b chess_char_6_20_b chess_char_5_12_b chess_char_5_20_b chess_char_6_05_b",
|
||||||
@@ -2952,7 +2952,7 @@
|
|||||||
"ticks": 6164,
|
"ticks": 6164,
|
||||||
"reason": "cleared",
|
"reason": "cleared",
|
||||||
"killed": 34,
|
"killed": 34,
|
||||||
"total": 87,
|
"total": 86,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 145,
|
"rngDraws": 145,
|
||||||
@@ -2960,7 +2960,7 @@
|
|||||||
"players": {
|
"players": {
|
||||||
"p1": {
|
"p1": {
|
||||||
"killed": 34,
|
"killed": 34,
|
||||||
"total": 87,
|
"total": 86,
|
||||||
"perfect": false,
|
"perfect": false,
|
||||||
"leaks": 53,
|
"leaks": 53,
|
||||||
"leaksUncounted": 0,
|
"leaksUncounted": 0,
|
||||||
@@ -2998,7 +2998,7 @@
|
|||||||
"hooks": [1,103,6164,249,249,2817,2826,30376,41,94,66,66,0,9498,399,25,87,53,1,74,9,1,0],
|
"hooks": [1,103,6164,249,249,2817,2826,30376,41,94,66,66,0,9498,399,25,87,53,1,74,9,1,0],
|
||||||
"events": {"atk":463,"bounty":1,"deploy":13,"die":41,"dmg":2826,"engage":5,"fx":129,"heal":44,"layer":9,"leak":53,"skill":132,"spawn":96,"status":835},
|
"events": {"atk":463,"bounty":1,"deploy":13,"die":41,"dmg":2826,"engage":5,"fx":129,"heal":44,"layer":9,"leak":53,"skill":132,"spawn":96,"status":835},
|
||||||
"fx": {"burst":1,"dollEnd":1,"explode":1,"garrison":8,"layer":1,"lightning":58,"shield":5,"slash":11,"sp":28,"storm":3,"substitute":3,"summon":1,"swap":2,"telegraph":1,"weightless":5},
|
"fx": {"burst":1,"dollEnd":1,"explode":1,"garrison":8,"layer":1,"lightning":58,"shield":5,"slash":11,"sp":28,"storm":3,"substitute":3,"summon":1,"swap":2,"telegraph":1,"weightless":5},
|
||||||
"snaps": "114138f7 91b6a2fa cc32ef2d c73b8b92 cfc9b573 b1f8d939 67a68be3 fbe5df15 f7521760 ef229a79 2c9fc754 fd4c140e 7704bbb0 20c916c8 38931f1b 403b9470 3735233a 40c9ddae c1216447 f0ba8a3b addc333e"
|
"snaps": "114138f7 91b6a2fa cc32ef2d c73b8b92 cfc9b573 7b3dd24c 0c53a125 af35b361 6fca06d6 2c88ae52 e6b5584c b5db6ac3 b1fe0c6c 1b308f78 394e7946 d6b4633c 08432cc7 25ebf66b 22366e40 448fd11e e921151e"
|
||||||
},
|
},
|
||||||
"roster-049": {
|
"roster-049": {
|
||||||
"about": "pass 3 band 5: chess_char_4_23_b chess_char_3_04_b chess_char_4_09_b chess_char_3_21_b chess_char_4_13_b",
|
"about": "pass 3 band 5: chess_char_4_23_b chess_char_3_04_b chess_char_4_09_b chess_char_3_21_b chess_char_4_13_b",
|
||||||
@@ -3013,12 +3013,12 @@
|
|||||||
"ticks": 5700,
|
"ticks": 5700,
|
||||||
"reason": "timeout",
|
"reason": "timeout",
|
||||||
"killed": 28,
|
"killed": 28,
|
||||||
"total": 64,
|
"total": 62,
|
||||||
"unspawned": 0,
|
"unspawned": 0,
|
||||||
"bossHpLeft": null,
|
"bossHpLeft": null,
|
||||||
"rngDraws": 0,
|
"rngDraws": 0,
|
||||||
"errors": {"count":0,"kinds":[]},
|
"errors": {"count":0,"kinds":[]},
|
||||||
"players": {"p1":{"killed":28,"total":64,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":6,"dmg":430513,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
|
"players": {"p1":{"killed":28,"total":62,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":6,"dmg":430513,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
|
||||||
"units": [
|
"units": [
|
||||||
["p1",1,"op","chess_char_4_23_b",2,"none",0,0,1,0,0,2661,0,0,0],
|
["p1",1,"op","chess_char_4_23_b",2,"none",0,0,1,0,0,2661,0,0,0],
|
||||||
["p1",2,"op","chess_char_3_04_b",1,"none",3289.5,1,74,1,17288,6579,0,88,0],
|
["p1",2,"op","chess_char_3_04_b",1,"none",3289.5,1,74,1,17288,6579,0,88,0],
|
||||||
@@ -3043,7 +3043,7 @@
|
|||||||
"hooks": [1,74,5700,336,336,1757,1757,0,34,69,31,32,0,3916,725,3,64,35,1,35,0,0,0],
|
"hooks": [1,74,5700,336,336,1757,1757,0,34,69,31,32,0,3916,725,3,64,35,1,35,0,0,0],
|
||||||
"events": {"atk":389,"bounty":3,"deploy":10,"die":34,"dmg":1756,"engage":4,"fx":47,"leak":35,"skill":67,"spawn":69,"status":415},
|
"events": {"atk":389,"bounty":3,"deploy":10,"die":34,"dmg":1756,"engage":4,"fx":47,"leak":35,"skill":67,"spawn":69,"status":415},
|
||||||
"fx": {"coin":9,"dilemma":3,"explode":2,"frostNova":25,"revive":1,"shield":2,"shieldBreak":1,"summon":1,"telegraph":3},
|
"fx": {"coin":9,"dilemma":3,"explode":2,"frostNova":25,"revive":1,"shield":2,"shieldBreak":1,"summon":1,"telegraph":3},
|
||||||
"snaps": "22feba20 4bc82bff c2ef8225 6fc18bf4 0986acbf 61e7f956 eb664c7f db99546b 96c386fa 8701aeb3 c2cb18a0 f881beec 96991826 da3b1101 c2a76f22 f89bc3d9 a6d90c11 4ec4cbb4 0b2fb538"
|
"snaps": "22feba20 4bc82bff 64cc615e 21da6144 58240a76 f0531e80 a0205176 3d77feab 942ccd22 485f3fd3 56333d9f 02add93c cbc1098a dd516fb0 72fb6266 e093854d 6173f536 de8c5328 1f0a2be6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,8 +366,15 @@ export function checkInvariants(b) {
|
|||||||
for (const pr of b.projectiles.list) if (![pr.x, pr.y, pr.tx, pr.ty].every(Number.isFinite)) bad(`projectile at ${pr.x},${pr.y} → ${pr.tx},${pr.ty}`);
|
for (const pr of b.projectiles.list) if (![pr.x, pr.y, pr.tx, pr.ty].every(Number.isFinite)) bad(`projectile at ${pr.x},${pr.y} → ${pr.tx},${pr.ty}`);
|
||||||
if (b._emitDepth) bad(`hook emit depth ${b._emitDepth} between steps`);
|
if (b._emitDepth) bad(`hook emit depth ${b._emitDepth} between steps`);
|
||||||
for (const pp of Object.values(b._perPlayer ?? {})) {
|
for (const pp of Object.values(b._perPlayer ?? {})) {
|
||||||
for (const k of ['killed', 'total', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
|
for (const k of ['killed', 'total', 'killedInTotal', 'leakedInTotal', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
|
||||||
|
// The capsule's counters are attributed differently on purpose: a knock-out goes to the enemy's owner (as its `total`
|
||||||
|
// did), while a leak goes to the player whose half the enemy reached (`_recordLeak`) — in a 联防 field an enemy
|
||||||
|
// spawned on one half and leaked on the other bills the leaker, so per player only `killedInTotal ≤ total` holds
|
||||||
|
// (BattleResult's per-player `resolved` clamps the sum to `total`, and the battle-level sum is exact)
|
||||||
|
if (pp.killedInTotal > pp.total) bad(`capsule ${pp.killedInTotal} > ${pp.total}`);
|
||||||
|
if (pp.total < 0 || pp.killedInTotal < 0 || pp.leakedInTotal < 0) bad('negative capsule counter');
|
||||||
}
|
}
|
||||||
|
if (b.killedInTotal + b.leakedInTotal > b.total) bad(`battle capsule ${b.killedInTotal}+${b.leakedInTotal} > ${b.total}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,14 @@ test('COMBAT: humans get their own spec (authoritative), bots are simulated by t
|
|||||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
||||||
m.flush(true);
|
m.flush(true);
|
||||||
const pub = m.publicView();
|
const pub = m.publicView();
|
||||||
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, total: 9, done: false });
|
// `resolved` is null while the authority has not reported one — never 0, so the client's `resolved ?? killed` fallback
|
||||||
|
// holds (a report without `resolved` must read as "unknown", not as "nothing resolved")
|
||||||
|
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: null, total: 9, done: false });
|
||||||
|
// a report that carries `resolved` is adopted, a reported 0 included (PR #157 capsule numerator)
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||||
|
m.flush(true);
|
||||||
|
const pub2 = m.publicView();
|
||||||
|
assert.deepEqual(pub2.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: 0, total: 9, done: false });
|
||||||
// a stale / foreign report is ignored (never an error toast)
|
// a stale / foreign report is ignored (never an error toast)
|
||||||
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: human.battleId, gt: 9, killed: 9, total: 9 }), { ok: true });
|
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: human.battleId, gt: 9, killed: 9, total: 9 }), { ok: true });
|
||||||
assert.equal(human.progress.killed, 3);
|
assert.equal(human.progress.killed, 3);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
|
// GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
|
||||||
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
|
// upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
|
||||||
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
|
// it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
|
||||||
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
|
// tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
|
||||||
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
|
// (item 19 of the community report of 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark);
|
||||||
|
// before that the shop kept its old slot count until the next roll. Either way the upgrade leaves the cards shown where they are.
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { makeMatch, DATA } from './harness.js';
|
import { makeMatch, DATA } from './harness.js';
|
||||||
@@ -11,7 +12,7 @@ import { GameData } from '../../server/match/gamedata.js';
|
|||||||
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
|
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
|
||||||
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
|
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
|
||||||
|
|
||||||
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
|
test('a level-up opens the new level\'s operator slot EMPTY; the cards shown stay in place; a refresh fills it', () => {
|
||||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
|
||||||
h.toPrep(1);
|
h.toPrep(1);
|
||||||
const ps = h.ps('p_0');
|
const ps = h.ps('p_0');
|
||||||
@@ -20,35 +21,37 @@ test('a level-up opens the new level\'s operator slot at once with a new card; t
|
|||||||
const s1 = ps.shop.slots.slice();
|
const s1 = ps.shop.slots.slice();
|
||||||
assert.equal(s1.length, 4);
|
assert.equal(s1.length, 4);
|
||||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
||||||
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
|
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), the new slot empty
|
||||||
assert.equal(ps.shop.level, 2);
|
assert.equal(ps.shop.level, 2);
|
||||||
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
|
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
|
||||||
assert.equal(ps.shop.slots.length, 5);
|
assert.equal(ps.shop.slots.length, 5);
|
||||||
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
|
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
|
||||||
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
|
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
|
||||||
const added = ps.shop.slots[3];
|
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
|
||||||
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
|
|
||||||
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
|
|
||||||
assert.equal(added.frozen, false);
|
|
||||||
// the player sees it right away (m.private shop)
|
// the player sees it right away (m.private shop)
|
||||||
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
|
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
|
||||||
// 2 → 3 keeps 4 operator slots: nothing rerolled
|
// a manual refresh fills every slot at the new level (the empty one included)
|
||||||
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||||
|
assert.equal(ps.shop.slots.length, 5);
|
||||||
|
for (const s of ps.shop.slots) assert.ok(s && !s.sold, 'every slot carries a fresh card after the refresh');
|
||||||
|
for (const s of ps.shop.slots) if (s.kind === 'chess') assert.ok(tierOf(s.id) >= 1 && tierOf(s.id) <= 2, `drawn at level 2 (tier ${tierOf(s.id)})`);
|
||||||
|
// 2 → 3 keeps 4 operator slots: nothing rerolled, nothing added
|
||||||
const s2 = ps.shop.slots.slice();
|
const s2 = ps.shop.slots.slice();
|
||||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||||
assert.equal(ps.shop.level, 3);
|
assert.equal(ps.shop.level, 3);
|
||||||
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
|
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
|
||||||
// 3 → 4: the fifth operator slot
|
// 3 → 4: the fifth operator slot, empty again
|
||||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||||
assert.equal(ps.shop.level, 4);
|
assert.equal(ps.shop.level, 4);
|
||||||
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
|
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
|
||||||
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
|
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
|
||||||
assert.equal(ps.shop.slots[5], s2[4]);
|
assert.equal(ps.shop.slots[5], s2[4]);
|
||||||
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
|
assert.equal(ps.shop.slots[4], null, 'the new slot is open and empty');
|
||||||
h.invariants();
|
h.invariants();
|
||||||
h.m.dispose();
|
h.m.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
|
test('a bought card stays sold; the empty new slot has nothing to freeze; the round start fills the shop to the new level', () => {
|
||||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
|
||||||
h.toPrep(1);
|
h.toPrep(1);
|
||||||
const ps = h.ps('p_0');
|
const ps = h.ps('p_0');
|
||||||
@@ -58,35 +61,43 @@ test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED]
|
|||||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
|
||||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||||
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
|
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
|
||||||
const added = ps.shop.slots[3];
|
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
|
||||||
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
|
|
||||||
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
|
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
|
||||||
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
|
assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
|
||||||
// the next round start keeps every frozen card (in place) and rolls only the sold slot
|
// the round start fills every slot of the new level: frozen cards kept in place, the sold and the empty slot rolled
|
||||||
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
|
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
|
||||||
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
|
|
||||||
assert.equal(ps.shop.slots.length, 5);
|
assert.equal(ps.shop.slots.length, 5);
|
||||||
|
for (const s of ps.shop.slots) assert.ok(s, 'the round start leaves no empty slot');
|
||||||
|
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id).slice(0, 2), keep.slice(0, 2), 'the frozen operator cards kept');
|
||||||
|
assert.equal(ps.shop.slots[4].id, keep[2], 'the frozen item kept after the operator slots');
|
||||||
|
assert.notEqual(ps.shop.slots[0].id, undefined, 'the sold slot rerolled');
|
||||||
h.invariants();
|
h.invariants();
|
||||||
h.m.dispose();
|
h.m.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
|
test('every mode\'s slot table: a level-up opens exactly the new positions, empty (标准: 3,4,4,4,4,5)', () => {
|
||||||
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
|
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
|
||||||
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
|
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
|
||||||
h.toPrep(1);
|
h.toPrep(1);
|
||||||
const ps = h.ps('p_0');
|
const ps = h.ps('p_0');
|
||||||
const gd = h.m.gd;
|
const gd = h.m.gd;
|
||||||
ps.funds = 500;
|
ps.funds = 500;
|
||||||
|
let empty = 0;
|
||||||
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
|
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
|
||||||
const before = ps.shop.slots.slice();
|
const before = ps.shop.slots.slice();
|
||||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
||||||
const { chess, item } = gd.shopSlots(lv);
|
const { chess, item } = gd.shopSlots(lv);
|
||||||
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
|
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
|
||||||
const grew = chess - gd.shopSlots(lv - 1).chess;
|
const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
|
||||||
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
|
const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
|
||||||
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
|
assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
|
||||||
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
|
// the empty positions accumulate: nothing fills them between the upgrades (no refresh / round start here)
|
||||||
|
empty += grew;
|
||||||
|
assert.equal(ps.shop.slots.filter((s) => !s).length, empty, `${mode} ${difficulty} L${lv}: the new positions are open and empty`);
|
||||||
}
|
}
|
||||||
|
// one refresh fills everything at the final level
|
||||||
|
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||||
|
for (const s of ps.shop.slots) assert.ok(s, `${mode} ${difficulty}: the refresh leaves no empty slot`);
|
||||||
h.invariants();
|
h.invariants();
|
||||||
h.m.dispose();
|
h.m.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// test/match/hud-capsule-resolved.test.js — the HUD capsule's server half (PR #157): m.public.fields[].progress carries
|
||||||
|
// the field's own `resolved` (knocked out + leaked among the enemies the round scheduled), and
|
||||||
|
// * a bot / server-run field has no authority reporting b.progress — its capsule must read the battle's own counters
|
||||||
|
// (`timelineSample` → Battle.resolved), not the report-driven `progress.leaks`, which stays 0 forever (maintainer
|
||||||
|
// review point ①);
|
||||||
|
// * `progress.resolved` starts at **null**, never 0: only a real reported number is adopted, so the client's
|
||||||
|
// `resolved ?? killed` fallback keeps working (a `Number(null) === 0` would defeat it) — and a reported 0 is a real
|
||||||
|
// value, told apart from "not reported" (review point ②);
|
||||||
|
// * a finished two-half field (联防 with two helpers, the boss pair) publishes the FIELD's own `resolved` — the capsule's
|
||||||
|
// number — never the sum of the players' own clamps: an enemy that spawns on one half and leaks on the other is
|
||||||
|
// billed to one player's `total` and to the other's `leakedInTotal`, so both players' min(total, …) read 0 for it
|
||||||
|
// while the field resolved it (found reviewing the capsule port, 2026-10-08).
|
||||||
|
// Run: node --test test/match/hud-capsule-resolved.test.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { PHASE } from '../../shared/constants.js';
|
||||||
|
import { makeMatch, DATA } from './harness.js';
|
||||||
|
import { timelineSample, timelineAt, validateClientResult, runHeadless } from '../../server/match/fields.js';
|
||||||
|
import { buildBattleSpec, createBattleFromSpec, compactResult, battleProgress } from '../../server/sim/spec.js';
|
||||||
|
import { GameData } from '../../server/match/gamedata.js';
|
||||||
|
import { MatchViews } from '../../server/match/match/views.js';
|
||||||
|
import { makeBattle } from '../helpers/battleHarness.js';
|
||||||
|
import { teammateProgress } from '../../public/js/battle/observe.js';
|
||||||
|
import { uniteLeftMark } from '../../public/js/battle/runner.js';
|
||||||
|
|
||||||
|
const fields = (h) => h.m.fields;
|
||||||
|
const progressOf = (m, fieldId) => (m.publicView().fields.find((f) => f.fieldId === fieldId) || {}).progress;
|
||||||
|
|
||||||
|
test('① 机器人战场 (mode server, 无上报): 胶囊读该战斗自己的计数器,不是停在 0 的 progress.leaks', () => {
|
||||||
|
// no FakeBattle: the bot's field runs the real sim on the server (HeadlessJob + timeline), and nobody reports b.progress
|
||||||
|
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, instant: false, clientCombat: true, clients: false }).start();
|
||||||
|
const m = h.m;
|
||||||
|
h.toPrep(1);
|
||||||
|
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||||
|
h.run(() => m.phase === PHASE.COMBAT);
|
||||||
|
const f = fields(h).find((x) => x.fieldId === 'n:ai_0');
|
||||||
|
assert.equal(f.mode, 'server', 'the bot field runs on the server');
|
||||||
|
assert.equal(f.authority, null, 'no authority ever reports it');
|
||||||
|
assert.ok(f.timeline && f.timeline.length > 1, 'it has a progress timeline');
|
||||||
|
assert.equal(f.progress.leaks, 0, 'nothing was reported: progress.leaks stays 0');
|
||||||
|
assert.equal(f.progress.resolved, null, 'and progress.resolved stays null (never a fabricated 0)');
|
||||||
|
|
||||||
|
let filled = 0;
|
||||||
|
for (let i = 0; i < 20 && !f.done; i++) {
|
||||||
|
h.sched.advance(2000);
|
||||||
|
const pr = progressOf(m, 'n:ai_0');
|
||||||
|
// the capsule reads the battle's own counters, sampled on the field clock
|
||||||
|
assert.equal(pr.resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'resolved = the timeline sample');
|
||||||
|
assert.ok(pr.resolved != null && pr.resolved <= pr.total, 'a real number, never above the denominator');
|
||||||
|
if (pr.resolved > 0) { filled = pr.resolved; break; }
|
||||||
|
}
|
||||||
|
assert.ok(filled > 0, 'the bot field\'s capsule fills up (a report-driven number would have stayed at 0)');
|
||||||
|
// the sample is composed of the battle's own two counters — the field's own scheduled enemies that were resolved
|
||||||
|
assert.equal(timelineSample(f.battle)[3], Math.min(f.battle.total, f.battle.killedInTotal + f.battle.leakedInTotal));
|
||||||
|
assert.equal(progressOf(m, 'n:ai_0').resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'clamped by the field clock');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('① 一场只漏不杀的机器人战场: resolved 由 leakedInTotal 顶起来 (不是 0)', () => {
|
||||||
|
// three walkers, nobody to stop them: the field's own enemies leak — leakedInTotal is what the capsule must show
|
||||||
|
const h = makeBattle({ seed: 5, enemies: [{ key: 'enemy_1005_yokai', count: 3 }], content: 'full' });
|
||||||
|
h.runToEnd(120);
|
||||||
|
assert.equal(h.b.total, 3);
|
||||||
|
assert.equal(h.b.leakedInTotal, 3, 'the field leaked all three of its own enemies');
|
||||||
|
assert.equal(h.b.leakedCount, 3, 'counted leaks — the LP charge, unchanged from master');
|
||||||
|
const sample = timelineSample(h.b);
|
||||||
|
assert.equal(sample[3], 3, 'the timeline sample carries the capsule numerator');
|
||||||
|
assert.equal(battleProgress(h.b).resolved, 3, 'b.progress too (what a teammate HUD reads)');
|
||||||
|
assert.equal(battleProgress(h.b).total, 3);
|
||||||
|
assert.equal(battleProgress(h.b).killed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('② 上报没有 resolved ⇒ progress.resolved = null,客户端回落到 killed;上报 resolved: 0 ⇒ 采用 0', () => {
|
||||||
|
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9103, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 6 }) }).start();
|
||||||
|
const m = h.m;
|
||||||
|
h.toPrep(1);
|
||||||
|
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||||
|
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||||
|
h.run(() => m.phase === PHASE.COMBAT);
|
||||||
|
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||||
|
assert.equal(human.mode, 'client');
|
||||||
|
assert.equal(human.progress.resolved, null, 'a fresh field: resolved unknown, not 0');
|
||||||
|
|
||||||
|
// a report without `resolved` (an older client / a field that cannot count it) leaves it unknown
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
|
||||||
|
m.flush(true);
|
||||||
|
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: null, total: 9, done: false });
|
||||||
|
// … and the teammate HUD falls back to `killed` (master's display: resolved ?? killed)
|
||||||
|
const mine = (pub) => teammateProgress(pub, 'p_1').find((x) => x.playerId === 'p_0');
|
||||||
|
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 3, total: 9, done: false });
|
||||||
|
|
||||||
|
// a reported 0 is a real value: adopted, and the HUD shows 0 (not the fallback)
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||||
|
m.flush(true);
|
||||||
|
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: 0, total: 9, done: false });
|
||||||
|
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 0, total: 9, done: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a done field\'s m.public progress carries the reported resolved (never a fabricated one)', () => {
|
||||||
|
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9104, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 2 }) }).start();
|
||||||
|
const m = h.m;
|
||||||
|
h.toPrep(1);
|
||||||
|
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||||
|
m.handle('p_1', { t: 'g.ready', ready: true });
|
||||||
|
h.run(() => m.phase === PHASE.COMBAT);
|
||||||
|
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||||
|
// a result carrying the capsule numbers: 5 counted knock-outs (a split child included), 3 of the round's own resolved
|
||||||
|
const result = {
|
||||||
|
reason: 'cleared', time: 6, killed: 5, total: 3, resolved: 3,
|
||||||
|
perPlayer: { p_0: { killed: 5, total: 3, resolved: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] } },
|
||||||
|
errors: 0,
|
||||||
|
};
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: human.battleId, result }), { ok: true });
|
||||||
|
m.flush(true);
|
||||||
|
const pr = progressOf(m, 'n:p_0');
|
||||||
|
assert.equal(pr.done, true);
|
||||||
|
assert.equal(pr.killed, 5, '`killed` keeps the counted reading (may exceed the denominator)');
|
||||||
|
assert.equal(pr.total, 3, 'the denominator is the round\'s own list');
|
||||||
|
assert.equal(pr.resolved, 3, 'the capsule numerator of the finished field');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 联防 battle\'s live-counter mark still moves for a spawn — `total` no longer does (a split child / a summon is outside the capsule)', () => {
|
||||||
|
// the runner re-reads the leakers' enemies still standing (uniteLeft) only when this mark moves; before the capsule
|
||||||
|
// counted the round's own enemies only, every counted spawn raised `total` and so moved it
|
||||||
|
const h = makeBattle({ seed: 41, autoFinish: false, timeLimit: 60, enemies: [{ key: 'enemy_1195_sfyin', pos: [9, 6] }] });
|
||||||
|
h.step();
|
||||||
|
const m0 = uniteLeftMark(h.b);
|
||||||
|
assert.equal(h.b.total, 1);
|
||||||
|
assert.equal(uniteLeftMark(h.b), m0, 'nothing happened: the same mark');
|
||||||
|
const child = h.spawn('enemy_1196_msfyin', { pos: [9, 4] }); // a runtime spawn: counted, outside the denominator
|
||||||
|
assert.ok(child && child.counted && !child.inTotal);
|
||||||
|
assert.equal(h.b.total, 1, 'the denominator did not move');
|
||||||
|
assert.notEqual(uniteLeftMark(h.b), m0, 'the mark did: the spawn is a standing enemy the counter must read');
|
||||||
|
const m1 = uniteLeftMark(h.b);
|
||||||
|
h.b.kill(child, null);
|
||||||
|
assert.notEqual(uniteLeftMark(h.b), m1, 'a knock-out moves it');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- an enemy that crosses the midline of a two-half field -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The capsule counts per FIELD (Battle.resolved = min(total, killedInTotal + leakedInTotal)), but the sim also keeps every
|
||||||
|
// player's own counters: `total` and a knock-out belong to the half the enemy spawned on, a leak to the half whose tile it
|
||||||
|
// reached (deploy.js _recordLeak). A lane that runs through both halves — the 联防 lane (col 18 → col 2), the boss pair's
|
||||||
|
// crossing routes — bills one enemy's `total` to one player and its leak to the other, so each player's own
|
||||||
|
// min(total, killedInTotal + leakedInTotal) clamps it away: 0 and 0 for an enemy the field resolved. A finished field must
|
||||||
|
// publish the field's own number (what the capsule showed), never the sum of the players' clamps.
|
||||||
|
|
||||||
|
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||||
|
/** m.public.fields[].progress of a finished client-combat field (the done branch of Match._fieldProgress). */
|
||||||
|
const doneView = (result, progress = { killed: 0, total: 0, leaks: 0, resolved: null }) => MatchViews.prototype._fieldProgress.call({}, { cc: true, done: true, result, progress });
|
||||||
|
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||||
|
|
||||||
|
/** A 联防 spec of two helpers who fielded nothing, and one escaped enemy that walks the whole lane (col 18 → col 2). */
|
||||||
|
function uniteCrossSpec() {
|
||||||
|
return buildBattleSpec({
|
||||||
|
battleId: 'mid-u', fieldId: 'u', kind: 'unite', seed: 11, modeId: 'mode_multi_normal', round: 1, stageId: 'act1autochess_m02',
|
||||||
|
rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, timeLimit: 90,
|
||||||
|
players: [
|
||||||
|
{ playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} }, // the half the lane starts on (col 18)
|
||||||
|
{ playerId: 'p_2', seat: 2, side: 'L', colOffset: 0, units: [], bonds: {} }, // the half it leaves on (col 2)
|
||||||
|
],
|
||||||
|
routes: [{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] }],
|
||||||
|
spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 1, sourcePlayerId: 'p_0', mods: { hpMul: 0.8, atkMul: 0.8, speedMul: 1, slot: 'N' } }],
|
||||||
|
flags: { layerGainsEnabled: false, dpInit: 10, dpPerSec: 1, dpMax: 99 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const newBattle = (spec) => createBattleFromSpec(spec, null, { recordEvents: false, quiet: true });
|
||||||
|
|
||||||
|
test('联防 whose escaped enemies enter on one helper\'s half and leak on the other\'s: the finished field publishes the capsule\'s own 4/4, not 0/4', () => {
|
||||||
|
let ran = null;
|
||||||
|
// every client runs the 联防 spec with the REAL sim (the fake battles only stand in for the normal round); the helpers
|
||||||
|
// fielded nothing, so the leaker's four escaped enemies walk the whole lane and leak on the far half
|
||||||
|
const realUnite = (result, spec) => {
|
||||||
|
if (spec.kind !== 'unite') return result;
|
||||||
|
const b = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true });
|
||||||
|
const res = b.runToEnd(4000);
|
||||||
|
ran = { res, capsule: battleProgress(b) };
|
||||||
|
return compactResult(res);
|
||||||
|
};
|
||||||
|
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
|
||||||
|
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}),
|
||||||
|
perPlayer: Object.fromEntries(['p_0', 'p_1', 'p_2'].map((pid) => [pid, { tamper: realUnite }])) }).start();
|
||||||
|
const m = h.m;
|
||||||
|
h.toPrep(1);
|
||||||
|
const unite = () => fields(h).find((x) => x.fieldId === 'u');
|
||||||
|
assert.ok(h.drive(() => (unite() && unite().done) || h.ended != null) && unite().done, 'the 联防 field finished');
|
||||||
|
const f = unite();
|
||||||
|
assert.equal(f.resultSource, 'client', 'the authority\'s own result was accepted');
|
||||||
|
assert.ok(ran, 'the authority ran the 联防 spec with the real sim');
|
||||||
|
|
||||||
|
// the premise: the enemies crossed the midline — `total` on the half they entered, the leaks on the half they left
|
||||||
|
const per = Object.values(ran.res.perPlayer);
|
||||||
|
assert.deepEqual(per.map((p) => p.total).sort(), [0, 4], 'every total is billed to the half the enemies spawned on');
|
||||||
|
assert.deepEqual(per.map((p) => p.leaked.length).sort(), [0, 4], 'every leak to the half they reached');
|
||||||
|
assert.deepEqual(per.map((p) => p.resolved), [0, 0], 'so each player\'s own min(total, …) reads 0');
|
||||||
|
assert.equal(ran.capsule.resolved, 4, 'while the field\'s capsule read 4/4');
|
||||||
|
assert.equal(ran.res.resolved, 4);
|
||||||
|
|
||||||
|
// what the server keeps and publishes is the field's number
|
||||||
|
assert.equal(f.result.resolved, 4, 'the validated result carries the field-level numerator');
|
||||||
|
m.flush(true);
|
||||||
|
assert.deepEqual(progressOf(m, 'u'), { killed: 0, resolved: 4, total: 4, done: true }, 'm.public: the capsule\'s own 4/4');
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the boss pair: an enemy spawned on one half that leaks on the other half\'s goal reads 1/1 in the published progress too', () => {
|
||||||
|
const PAIR = [
|
||||||
|
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} },
|
||||||
|
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {} },
|
||||||
|
];
|
||||||
|
// route 3 of the flat boss stage: the centre gate (col 10, the left half's) → the right goal (col 18, the right half's)
|
||||||
|
const h = makeBattle({ kind: 'boss', players: PAIR, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 3 }], timeLimit: Infinity, content: 'none', autoFinish: true });
|
||||||
|
h.runToEnd(120);
|
||||||
|
const res = h.result();
|
||||||
|
assert.equal(res.reason, 'cleared');
|
||||||
|
assert.equal(res.perPlayer.L.total, 1, 'billed to the half it spawned on');
|
||||||
|
assert.equal(res.perPlayer.R.leaked.length, 1, 'leaked on the other half');
|
||||||
|
assert.deepEqual([res.perPlayer.L.resolved, res.perPlayer.R.resolved], [0, 0], 'each player\'s own clamp reads 0');
|
||||||
|
assert.equal(res.resolved, 1, 'the field resolved it');
|
||||||
|
|
||||||
|
const spec = buildBattleSpec({ battleId: 'mid-b', fieldId: 'b:pair', kind: 'boss', seed: 3, round: 8, players: PAIR,
|
||||||
|
spawns: [{ time: 0, enemyKey: 'enemy_1007_slime', routeIndex: 3, count: 1 }], flags: { layerGainsEnabled: false } });
|
||||||
|
const v = validateClientResult(spec, compactResult(res), { gd });
|
||||||
|
assert.ok(v.ok, v.reason);
|
||||||
|
assert.equal(v.result.resolved, 1);
|
||||||
|
assert.deepEqual(doneView(v.result), { killed: 0, resolved: 1, total: 1, done: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server-run 联防 field (a bot, a takeover) reads the same: Battle.result()\'s field-level number, and so does its timeline', () => {
|
||||||
|
const spec = uniteCrossSpec();
|
||||||
|
const run = runHeadless(newBattle(spec), { players: spec.players.map((p) => p.playerId) });
|
||||||
|
const res = run.result;
|
||||||
|
assert.equal(res.reason, 'cleared');
|
||||||
|
assert.deepEqual(Object.values(res.perPlayer).map((p) => [p.total, p.leaked.length, p.resolved]).sort(), [[0, 1, 0], [1, 0, 0]], 'the lane crossed the midline');
|
||||||
|
assert.equal(res.resolved, 1);
|
||||||
|
assert.deepEqual(doneView(res), { killed: 0, resolved: 1, total: 1, done: true }, 'the done field of a server run');
|
||||||
|
assert.equal(run.timeline[run.timeline.length - 1][3], 1, 'the live timeline sample of that field read 1 too');
|
||||||
|
// SP_VERIFY compares a client result with the server's run: both go through the same validation
|
||||||
|
assert.equal(validateClientResult(spec, compactResult(res), { gd }).result.resolved, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateClientResult: the field-level resolved is kept as reported (clamped to the field\'s total); the players\' own numbers are only the fallback', () => {
|
||||||
|
const spec = uniteCrossSpec();
|
||||||
|
const raw = compactResult(newBattle(spec).runToEnd(200));
|
||||||
|
assert.equal(raw.resolved, 1);
|
||||||
|
const checked = (edit) => {
|
||||||
|
const r = clone(raw);
|
||||||
|
edit(r);
|
||||||
|
const v = validateClientResult(spec, r, { gd });
|
||||||
|
assert.ok(v.ok, v.reason);
|
||||||
|
return v.result;
|
||||||
|
};
|
||||||
|
assert.equal(checked(() => {}).resolved, 1, 'as the client reported it (the per-player numbers are both 0 here)');
|
||||||
|
assert.equal(checked((r) => { r.resolved = 0; }).resolved, 0, 'a reported 0 is a value');
|
||||||
|
assert.equal(checked((r) => { r.resolved = 99; }).resolved, 1, 'never above the field\'s total');
|
||||||
|
// an older client (or a hand-made result) that reports no field-level number: the players' own, when every one has one
|
||||||
|
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 1; }).resolved, 1, 'the fallback is the sum of the players\' own numbers');
|
||||||
|
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 5; r.perPlayer.p_2.resolved = 5; }).resolved, 1, 'the fallback too is clamped to the total');
|
||||||
|
// a number that is not a non-negative integer counts as not reported
|
||||||
|
for (const junk of [-1, 0.5, '1', null, NaN]) assert.equal(checked((r) => { r.resolved = junk; r.perPlayer.p_1.resolved = 1; }).resolved, 1, `junk ${String(junk)} → the fallback`);
|
||||||
|
// nothing reported anywhere, or one player without a number: absent — the teammate UI falls back to `killed`, never to a fabricated 0
|
||||||
|
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; }), false);
|
||||||
|
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_2.resolved; }), false);
|
||||||
|
assert.equal(doneView(checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; })).resolved, null, 'm.public: null, not 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a finished field\'s progress: the result\'s field-level numerator wins, the players\' own are the fallback, a synthetic result keeps the field\'s live report', () => {
|
||||||
|
const pp = (o) => ({ killed: 0, total: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, ...o });
|
||||||
|
// the field-level number wins over the players' own (a crossing field sums to less than it resolved)
|
||||||
|
assert.deepEqual(doneView({ perPlayer: { a: pp({ total: 3, resolved: 0 }), b: pp({ total: 0, resolved: 0 }) }, resolved: 2 }), { killed: 0, resolved: 2, total: 3, done: true });
|
||||||
|
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 9 }).resolved, 3, 'clamped to the published total');
|
||||||
|
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 0 }).resolved, 0, 'a reported 0 is a value');
|
||||||
|
// no field-level number: the sum of the players' own when every one reported, else unknown (never a fabricated 0)
|
||||||
|
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({ resolved: 2 }) } }).resolved, 3);
|
||||||
|
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({}) } }).resolved, null);
|
||||||
|
assert.equal(doneView({ perPlayer: { a: pp({}) } }).resolved, null);
|
||||||
|
// a synthetic result (the field could not run: zeroes everywhere) shows the last live report instead
|
||||||
|
const synthetic = { synthetic: true, resolved: 0, perPlayer: { a: pp({ total: 0, resolved: 0 }) } };
|
||||||
|
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: 3 }), { killed: 2, resolved: 3, total: 5, done: true });
|
||||||
|
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: null }), { killed: 2, resolved: null, total: 5, done: true });
|
||||||
|
});
|
||||||
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
|
|||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
|
||||||
|
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
|
||||||
|
// time") but the lobby tests only removed one in the lobby: this is the running match.
|
||||||
|
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
|
||||||
|
FakeBattle.reset();
|
||||||
|
FakeBattle.script = () => ({ duration: 1 });
|
||||||
|
const watcher = async (name) => {
|
||||||
|
const srvNow = await server();
|
||||||
|
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
|
||||||
|
clients.push(c);
|
||||||
|
const w = await c.hello(name);
|
||||||
|
c.id = w.playerId;
|
||||||
|
c.token = w.token;
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
const a = await player('A');
|
||||||
|
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||||
|
const st = await a.waitFor('room.state');
|
||||||
|
await ok(a, { t: 'room.addBot' });
|
||||||
|
const s1 = await watcher('Spec1');
|
||||||
|
const s2 = await watcher('Spec2');
|
||||||
|
await ok(s1, { t: 'room.spectate', code: st.code });
|
||||||
|
await ok(s2, { t: 'room.spectate', code: st.code });
|
||||||
|
await ok(a, { t: 'room.start' });
|
||||||
|
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||||
|
await ok(a, { t: 'g.infoReady' });
|
||||||
|
await pickCoopBands([a]);
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||||
|
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||||
|
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
|
||||||
|
|
||||||
|
// who may remove: the host only, and only a spectator of this room
|
||||||
|
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
|
||||||
|
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
|
||||||
|
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
|
||||||
|
|
||||||
|
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
|
||||||
|
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
|
||||||
|
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
|
||||||
|
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
|
||||||
|
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
|
||||||
|
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
|
||||||
|
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
|
||||||
|
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
|
||||||
|
|
||||||
|
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
|
||||||
|
const mark = s1.log.length;
|
||||||
|
s2.log.length = 0;
|
||||||
|
await ok(a, { t: 'g.ready', ready: true });
|
||||||
|
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
|
||||||
|
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
|
||||||
|
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
|
||||||
|
|
||||||
|
// the freed seat can be taken again (no ban list: the key is all it takes)
|
||||||
|
const s3 = await watcher('Spec3');
|
||||||
|
await ok(s3, { t: 'room.spectate', code: st.code });
|
||||||
|
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||||
|
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await ok(a, { t: 'g.leave' });
|
||||||
|
});
|
||||||
|
|
||||||
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
|
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
|
||||||
FakeBattle.reset();
|
FakeBattle.reset();
|
||||||
FakeBattle.script = () => ({ duration: 1 });
|
FakeBattle.script = () => ({ duration: 1 });
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ test('observeTarget: prep, own battle running / over, boss pairs, eliminated pla
|
|||||||
assert.deepEqual(observeTarget(players[1], combat(true), 'a', { ownDone: true }), { fieldId: 'n:b' }, 'the local battle already ended');
|
assert.deepEqual(observeTarget(players[1], combat(true), 'a', { ownDone: true }), { fieldId: 'n:b' }, 'the local battle already ended');
|
||||||
assert.deepEqual(observeTarget(players[1], combat(false), 'a'), { fieldId: 'n:b' });
|
assert.deepEqual(observeTarget(players[1], combat(false), 'a'), { fieldId: 'n:b' });
|
||||||
assert.deepEqual(observeTarget(players[0], combat(false), 'a', { observing: true }), { back: true });
|
assert.deepEqual(observeTarget(players[0], combat(false), 'a', { observing: true }), { back: true });
|
||||||
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, total: 8, done: false }]);
|
// the teammate's capsule reads `resolved` (the field's own enemies knocked out or leaked); a report without one falls
|
||||||
|
// back to `killed` (public/js/battle/observe.js teammateProgress)
|
||||||
|
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, resolved: 3, total: 8, done: false }]);
|
||||||
|
const reported = pubOf('COMBAT', [{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true, progress: { killed: 5, resolved: 3, total: 8, done: false } }], players);
|
||||||
|
assert.deepEqual(teammateProgress(reported, 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 5, resolved: 3, total: 8, done: false }],
|
||||||
|
'a reported `resolved` wins over `killed` (5 counted knock-outs — splits included —, 3 of the round\'s own enemies resolved)');
|
||||||
const fa = pubOf('FINAL_ASSAULT', [
|
const fa = pubOf('FINAL_ASSAULT', [
|
||||||
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
|
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
|
||||||
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
|
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
|
||||||
|
|||||||
@@ -430,7 +430,13 @@ test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter
|
|||||||
seen.push(p.uniteLeft);
|
seen.push(p.uniteLeft);
|
||||||
h.sched.advance(500);
|
h.sched.advance(500);
|
||||||
}
|
}
|
||||||
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
|
// the split children are runtime spawns: they enter neither part of the HUD capsule (PR #157: the denominator counts
|
||||||
|
// only what the 联防 scheduled — `b.total` — and the numerator only that set's own knock-outs / leaks), while the
|
||||||
|
// 联防 live counter (m.public uniteLeft) still bills them to the leaker
|
||||||
|
const kids = b.units.filter((u) => u.side === 'enemy' && !u.inTotal).length;
|
||||||
|
assert.ok(kids > 0, `the 磨砻 split into ${kids} runtime children`);
|
||||||
|
assert.equal(b.total, SENT, `the children stay out of the capsule's denominator (total = the ${SENT} the 联防 scheduled)`);
|
||||||
|
assert.equal(b.resolved, Math.min(SENT, b.killedInTotal + b.leakedInTotal), 'the capsule numerator counts only the 联防\'s own enemies');
|
||||||
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
|
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
|
||||||
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
|
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
|
||||||
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
|
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export class SimClient {
|
|||||||
_progress(e) {
|
_progress(e) {
|
||||||
const p = battleProgress(e.battle);
|
const p = battleProgress(e.battle);
|
||||||
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
|
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
|
||||||
|
// the HUD capsule's numerator, like public/js/battle/runner.js (kept only when the battle reports one)
|
||||||
|
if (Number.isFinite(p.resolved)) msg.resolved = Math.min(p.resolved, p.total);
|
||||||
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
|
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
|
||||||
if (bossLike) {
|
if (bossLike) {
|
||||||
const pool = e.battle.sharedBoss;
|
const pool = e.battle.sharedBoss;
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ test('real data: R5 template on act1 m04 (crates) with a real lineup runs to the
|
|||||||
assert.ok(h.b.allyUnits.some((u) => u.kind === 'device'), 'm04 crates present');
|
assert.ok(h.b.allyUnits.some((u) => u.kind === 'device'), 'm04 crates present');
|
||||||
const r = h.result();
|
const r = h.result();
|
||||||
const p = r.perPlayer.p1;
|
const p = r.perPlayer.p1;
|
||||||
assert.equal(p.killed + p.leaked.filter((l) => l.counted).length, p.total, 'every counted enemy is killed or leaked');
|
// the capsule identity (PR #157): this round's own scheduled enemies are all 已解决 — knocked down or leaked. It
|
||||||
|
// replaces `killed + counted leaks = total`, which mixed the counted reading (runtime splits / summons included) with
|
||||||
|
// the scheduled denominator: `killed` may now exceed `total`, so only `≥` holds for it, while the capsule pair is exact
|
||||||
|
assert.ok(p.killed + p.leaked.filter((l) => l.counted).length >= p.total, 'every counted enemy is killed or leaked');
|
||||||
|
assert.equal(p.killedInTotal + p.leakedInTotal, p.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
|
||||||
|
assert.equal(p.resolved, p.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generic kit never throws and yields finite numbers for every real chess (normal & elite)', () => {
|
test('generic kit never throws and yields finite numbers for every real chess (normal & elite)', () => {
|
||||||
|
|||||||
+14
-2
@@ -106,9 +106,21 @@ test('fuzz: 200 random battles on real data run to completion with invariants',
|
|||||||
const r = b.result();
|
const r = b.result();
|
||||||
stats[r.reason]++;
|
stats[r.reason]++;
|
||||||
for (const [pid, pp] of Object.entries(r.perPlayer)) {
|
for (const [pid, pp] of Object.entries(r.perPlayer)) {
|
||||||
assert.ok(pp.killed <= pp.total || kind === 'unite', `${pid} killed ≤ total`);
|
// the capsule's own pair: `total` counts only the enemies this field scheduled (`inTotal`), and its numerator
|
||||||
|
// never exceeds it. `killed` deliberately keeps the `counted` reading — a runtime split child / summon knocked
|
||||||
|
// down counts there — so `killed > total` is legal since PR #157's capsule semantics (a 4-磨砻 round killed 4
|
||||||
|
// plus a child reads 5/4 on nothing but `killed/total`; the capsule reads resolved/total = 4/4)
|
||||||
|
assert.ok(pp.killedInTotal <= pp.total, `${pid} killedInTotal ≤ total`);
|
||||||
|
assert.ok(pp.resolved <= pp.total, `${pid} resolved ≤ total`);
|
||||||
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
|
for (const k of ['damageDealt', 'healingDone', 'bossDamage']) assert.ok(Number.isFinite(pp[k]) && pp[k] >= 0, `${k} finite`);
|
||||||
if (r.reason === 'cleared' && kind === 'normal') assert.equal(pp.killed + pp.leaked.filter((l) => l.counted).length, pp.total, 'every counted enemy killed or leaked');
|
// the capsule identity: every one of the field's own enemies is 已解决 — knocked down (killedInTotal) or leaked
|
||||||
|
// (leakedInTotal). It replaces the old `killed + counted leaks = total`, which mixed the counted reading with the
|
||||||
|
// scheduled denominator and no longer holds (killed counts runtime children, total does not)
|
||||||
|
if (r.reason === 'cleared' && kind === 'normal') {
|
||||||
|
assert.ok(pp.killed + pp.leaked.filter((l) => l.counted).length >= pp.total, 'every counted enemy killed or leaked');
|
||||||
|
assert.equal(pp.killedInTotal + pp.leakedInTotal, pp.total, 'the capsule is full: killedInTotal + leakedInTotal = total');
|
||||||
|
assert.equal(pp.resolved, pp.total, 'a cleared normal field resolves its own list');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
JSON.stringify(r);
|
JSON.stringify(r);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// test/sim/hud-capsule-resolved.test.js — the HUD capsule's own counters (PR #157, maintainer-reviewed direction; the
|
||||||
|
// bounty / kill semantics stay master's):
|
||||||
|
// resolved = min(total, killedInTotal + leakedInTotal) — the field's OWN scheduled enemies (`inTotal`) that are
|
||||||
|
// 已解决: knocked down or leaked. The official example: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
|
||||||
|
// A runtime spawn — a split child, a summon (a boss's included), a part — is in neither the numerator nor the
|
||||||
|
// denominator, while `counted` (the LP / 完美作战 / `killed` reading) keeps counting it exactly as master did.
|
||||||
|
// Run: node --test test/sim/hud-capsule-resolved.test.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||||
|
import { getData } from '../../server/data.js';
|
||||||
|
import { GameData } from '../../server/match/gamedata.js';
|
||||||
|
import { setupMatchWaves, buildBossWave } from '../../server/match/waves.js';
|
||||||
|
import { createRng, deriveSeed } from '../../server/sim/rng.js';
|
||||||
|
|
||||||
|
const QUIET = { warn() {}, error() {}, info() {}, log() {}, debug() {} };
|
||||||
|
/** The real mode config: the per-round LP cap settlement charges min(cap, counted leaks) with (match/settle.js). */
|
||||||
|
const gd = new GameData(getData({ log: QUIET }), 'mode_multi_normal');
|
||||||
|
|
||||||
|
const MOLANG = 'enemy_1195_sfyin'; // 磨砻 — 被击倒后生成2个<木制瑞印> (data talents DeadSpawn)
|
||||||
|
const MOLANG_CHILD = 'enemy_1196_msfyin'; // 木制瑞印
|
||||||
|
const APOSTLE = 'enemy_1321_wdarft'; // 枯朽萃聚使徒 — 召唤数个<枯朽之种> while it lives
|
||||||
|
const SEED = 'enemy_1269_nhfly'; // 枯朽之种
|
||||||
|
const done = (h) => {
|
||||||
|
checkInvariants(h.b);
|
||||||
|
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
|
||||||
|
};
|
||||||
|
const capsule = (b) => ({ total: b.total, killedInTotal: b.killedInTotal, leakedInTotal: b.leakedInTotal, resolved: b.resolved });
|
||||||
|
|
||||||
|
test('官方例子: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3 (分裂子体不进胶囊)', () => {
|
||||||
|
const h = makeBattle({
|
||||||
|
seed: 11, autoFinish: false, timeLimit: 300,
|
||||||
|
enemies: [
|
||||||
|
{ key: 'enemy_1195_sfyin', pos: [9, 6], count: 1 },
|
||||||
|
{ key: 'enemy_1005_yokai', pos: [9, 8], count: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
const molang = h.b.enemies.find((e) => e.defId === MOLANG);
|
||||||
|
const yokai = h.b.enemies.filter((e) => e.defId === 'enemy_1005_yokai');
|
||||||
|
assert.equal(h.b.total, 3, '分母 = 本关自己排定的 3 个敌人');
|
||||||
|
assert.ok(molang.inTotal && yokai.every((e) => e.inTotal), '排定的敌人 inTotal');
|
||||||
|
assert.equal(h.b.resolved, 0, '0/3');
|
||||||
|
const snap = h.snapshot();
|
||||||
|
assert.equal(snap.resolved, 0, 'b.snap carries the capsule numerator');
|
||||||
|
assert.equal(snap.total, 3);
|
||||||
|
|
||||||
|
h.b.leak(yokai[0]); // 漏一个
|
||||||
|
assert.equal(h.b.resolved, 1, '1/3');
|
||||||
|
|
||||||
|
h.b.kill(yokai[1], null); // 打死一个
|
||||||
|
assert.equal(h.b.resolved, 2, '2/3');
|
||||||
|
|
||||||
|
h.b.kill(molang, null); // 打死会分裂的
|
||||||
|
assert.equal(h.b.killedInTotal + h.b.leakedInTotal, 3, '三个排定的敌人都已解决 (2 击倒 + 1 漏掉)');
|
||||||
|
assert.equal(h.b.killedInTotal, 2);
|
||||||
|
assert.equal(h.b.leakedInTotal, 1);
|
||||||
|
assert.equal(h.b.resolved, 3, '3/3');
|
||||||
|
assert.equal(h.b.total, 3, '分裂出来的子体没有抬高分母');
|
||||||
|
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
|
||||||
|
assert.equal(kids.length, 2, '磨砻 split into 2 木制瑞印');
|
||||||
|
for (const k of kids) assert.ok(!k.inTotal && k.counted, 'a split child counts for LP, not for the capsule');
|
||||||
|
assert.equal(h.b.resolved, 3, 'the children move no capsule number');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('磨砻 4 个全被击倒 → 胶囊 4/4;它们 8 个子体全漏 → leakedInTotal 仍 0、counted 口径的 LP 照扣 min(10, 8) = 8', () => {
|
||||||
|
const h = makeBattle({ seed: 12, autoFinish: false, timeLimit: 300, enemies: [{ key: MOLANG, pos: [9, 6], count: 4 }] });
|
||||||
|
h.step();
|
||||||
|
const molang = h.b.enemies.filter((e) => e.defId === MOLANG && e.alive);
|
||||||
|
assert.equal(molang.length, 4);
|
||||||
|
assert.equal(h.b.total, 4, '分母 = 排定的 4 个磨砻');
|
||||||
|
for (const e of molang) h.b.kill(e, null);
|
||||||
|
assert.equal(h.b.killedInTotal, 4);
|
||||||
|
assert.equal(h.b.resolved, 4, '胶囊 4/4 — 击倒的是本关自己的敌人');
|
||||||
|
assert.equal(h.b.total, 4, '子体不进分母');
|
||||||
|
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
|
||||||
|
assert.equal(kids.length, 8, '四个磨砻各分裂出 2 个木制瑞印');
|
||||||
|
assert.equal(h.b.leakedInTotal, 0);
|
||||||
|
|
||||||
|
for (const k of kids) h.b.leak(k);
|
||||||
|
// the capsule: the leaked children are no enemies of the stage's own list — the numerator stays at 4/4
|
||||||
|
assert.deepEqual(capsule(h.b), { total: 4, killedInTotal: 4, leakedInTotal: 0, resolved: 4 });
|
||||||
|
// the LP reading is master's, unchanged: every leaked child is a counted leak, and settlement charges min(cap, 8)
|
||||||
|
// (server/match/settle.js `Math.min(cap, counted)`); leakedCount is the count the LP meter reads
|
||||||
|
assert.equal(h.b.leakedCount, 8);
|
||||||
|
const r = h.result();
|
||||||
|
const countedLeaks = r.perPlayer.p1.leaked.filter((l) => l.counted !== false).length;
|
||||||
|
assert.equal(countedLeaks, 8, '8 counted leaks — the children each cost LP as before');
|
||||||
|
assert.equal(Math.min(gd.lpCapPerRound, countedLeaks), 8, 'the settled charge is min(10, 8) = 8, exactly as master');
|
||||||
|
assert.equal(r.perPlayer.p1.perfect, false, 'a counted leak still breaks 完美作战');
|
||||||
|
assert.equal(r.perPlayer.p1.resolved, 4, 'BattleResult carries the capsule numerator');
|
||||||
|
assert.equal(r.resolved, 4);
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('分裂子体被击倒: 胶囊数字不动,`killed` 仍按 counted 口径 +1 (master 的击杀语义不变)', () => {
|
||||||
|
const h = makeBattle({ seed: 13, autoFinish: false, timeLimit: 300, enemies: [{ key: MOLANG, pos: [9, 6] }] });
|
||||||
|
h.step();
|
||||||
|
const molang = h.b.enemies.find((e) => e.defId === MOLANG);
|
||||||
|
h.b.kill(molang, null);
|
||||||
|
const kids = h.b.enemies.filter((e) => e.alive && e.defId === MOLANG_CHILD);
|
||||||
|
assert.equal(kids.length, 2);
|
||||||
|
const before = capsule(h.b);
|
||||||
|
const killedBefore = h.b.killed;
|
||||||
|
const coinsBefore = h.result().perPlayer.p1.coins;
|
||||||
|
h.b.kill(kids[0], null);
|
||||||
|
assert.deepEqual(capsule(h.b), before, 'a knocked-down split child moves no capsule number');
|
||||||
|
assert.equal(h.b.killed, killedBefore + 1, '`killed` counts every counted knock-out (master semantics, unchanged)');
|
||||||
|
assert.equal(h.result().perPlayer.p1.coins, coinsBefore, 'no bounty for a split child');
|
||||||
|
h.b.leak(kids[1]);
|
||||||
|
assert.deepEqual(capsule(h.b), before, 'a leaked split child moves no capsule number either');
|
||||||
|
assert.equal(h.b.leakedCount, 1, '… but it is a counted leak (LP / 完美作战)');
|
||||||
|
assert.equal(h.b.resolved, 1, 'the capsule stands at 1/1 — the 磨砻 itself');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('枯朽萃聚使徒: 召唤出的枯朽之种出生 / 被击倒 / 漏掉都不动胶囊,本体照旧结算 (悬赏口径不变)', () => {
|
||||||
|
const card = { id: 'enemyeffect_10_8', card: { payout: 'kill', coin: 2, enemyKey: APOSTLE } };
|
||||||
|
const h = makeBattle({
|
||||||
|
seed: 14, autoFinish: false, timeLimit: 300,
|
||||||
|
enemies: [{ key: APOSTLE, route: 2, mods: { hpMul: 1, bountyId: card.id }, bounty: { coins: 2, ownerPlayerId: 'p1' }, tag: 'bounty', ownerPlayerId: 'p1' }],
|
||||||
|
});
|
||||||
|
assert.ok(h.runUntil(() => h.b.enemies.filter((e) => e.alive && e.defId === SEED).length >= 2, 30), 'BornBugs cast');
|
||||||
|
const seeds = h.b.enemies.filter((e) => e.alive && e.defId === SEED);
|
||||||
|
assert.equal(h.b.total, 1, '分母只有排定的本体 — 召唤物不进分母');
|
||||||
|
for (const s of seeds) {
|
||||||
|
assert.ok(!s.inTotal, 'a summoned 枯朽之种 is not in the capsule');
|
||||||
|
assert.ok(s.counted, '… but it counts for LP / 完美作战 (master)');
|
||||||
|
assert.equal(s.bounty, null, '悬赏只在召唤物本体上 (master rule, untouched)');
|
||||||
|
}
|
||||||
|
const before = capsule(h.b);
|
||||||
|
const killedBefore = h.b.killed;
|
||||||
|
h.b.kill(seeds[0], null);
|
||||||
|
assert.deepEqual(capsule(h.b), before, 'the summoned seed\'s knock-out moves no capsule number');
|
||||||
|
assert.equal(h.b.killed, killedBefore + 1, '`killed` still counts it (master)');
|
||||||
|
assert.deepEqual(h.eventsOf('bounty'), [], 'a summoned seed pays no bounty');
|
||||||
|
h.b.leak(seeds[1]);
|
||||||
|
assert.deepEqual(capsule(h.b), before, 'the summoned seed\'s leak moves no capsule number');
|
||||||
|
assert.equal(h.b.leakedCount, 1, 'counted leak: the LP charge is unchanged from master');
|
||||||
|
// the body itself is the field's own scheduled enemy: its knock-out resolves the capsule and pays once
|
||||||
|
const body = h.b.enemies.find((e) => e.alive && e.defId === APOSTLE);
|
||||||
|
assert.ok(body && body.inTotal);
|
||||||
|
h.b.kill(body, null);
|
||||||
|
assert.deepEqual(h.eventsOf('bounty'), [['bounty', 'p1', 2]], 'the body pays once (master rule)');
|
||||||
|
assert.equal(h.b.resolved, h.b.total, 'the field\'s own list is resolved');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================================================================
|
||||||
|
// A real Boss round: a boss summon is a runtime spawn too. The 假想敌:管 (boss_3, act1autochess_h07_03) summons a
|
||||||
|
// 余音 (enemy_9023_acdums) every 40 game s (20 below half HP); its wave ALSO schedules 2 余音 of its own — those are
|
||||||
|
// the round's own enemies and do enter the capsule, the summoned ones do not.
|
||||||
|
|
||||||
|
test('Boss 召唤物 (假想敌:管的余音) 出生 / 被击倒 / 漏掉都不动胶囊;Boss 死后 resolved === total', () => {
|
||||||
|
const bossId = 'boss_3';
|
||||||
|
const ECHO = 'enemy_9023_acdums'; // 余音 — 管 summons it every 40 game s (20 s below half HP)
|
||||||
|
const seed = deriveSeed(20261005, `boss:${bossId}:p`);
|
||||||
|
const wave = buildBossWave(gd, createRng(deriveSeed(seed, 'waves')), setupMatchWaves(gd, createRng(deriveSeed(seed, 'setup'))), gd.bossRound, { bossId, solo: false });
|
||||||
|
const h = makeBattle({
|
||||||
|
kind: 'boss', seed: 21, autoFinish: false,
|
||||||
|
routes: wave.routes,
|
||||||
|
enemies: wave.spawns.map((s) => ({ key: s.enemyKey, time: s.time, route: s.routeIndex, count: s.count, interval: s.interval, mods: s.mods, tag: s.tag, countInTotal: s.countInTotal })),
|
||||||
|
});
|
||||||
|
// the round's own list: 2 疏术士 + 2 疏den + 2 余音 (the wave's own 余音, scheduled 40 s apart) — the 领袖 (tag 'boss')
|
||||||
|
// and everything 管 summons are outside it. 会召唤的 Boss 关胶囊 therefore fills up now (the old `total++` of a runtime
|
||||||
|
// spawn left it short forever).
|
||||||
|
const scheduled = wave.spawns.filter((s) => s.tag !== 'boss' && s.countInTotal !== false).reduce((n, s) => n + s.count, 0);
|
||||||
|
assert.equal(scheduled, 6);
|
||||||
|
assert.equal(h.b.total, scheduled, `分母 = 本关排定且该计入的 ${scheduled} 个敌人`);
|
||||||
|
h.run(3); // the 领袖 comes in at t = 1 s, the wave's first 余音 at t = 0
|
||||||
|
const leader = h.b.enemies.find((e) => e.defId === 'enemy_9021_acduml');
|
||||||
|
assert.ok(leader, 'the 领袖 is on the field');
|
||||||
|
assert.ok(!leader.inTotal && !leader.counted, 'the 领袖 (tag boss) is in no capsule part');
|
||||||
|
assert.equal(h.b.units.filter((u) => u.side === 'enemy' && u.inTotal && u.defId === ECHO).length, 1, 'the wave scheduled its own 余音');
|
||||||
|
assert.equal(h.b.total, scheduled, 'the 领袖 does not add to the denominator (tag boss ⇒ not counted)');
|
||||||
|
|
||||||
|
// the boss summons one of its own (pipeCore, bosses.js: `spawnEnemy` — never the spawn queue)
|
||||||
|
const runtime = () => h.b.units.find((u) => u.side === 'enemy' && u.alive && !u.inTotal && u.defId === ECHO);
|
||||||
|
assert.ok(h.runUntil(() => !!runtime(), 90), 'the boss summoned a 余音');
|
||||||
|
const first = runtime();
|
||||||
|
assert.ok(first.counted, 'the summon still counts for LP (master)');
|
||||||
|
assert.equal(h.b.total, scheduled, 'the summoned 余音 never raised the denominator (the old defect: an unfillable capsule)');
|
||||||
|
const before = capsule(h.b);
|
||||||
|
const killedBefore = h.b.killed;
|
||||||
|
h.b.kill(first, null);
|
||||||
|
assert.deepEqual(capsule(h.b), before, 'a knocked-down boss summon moves no capsule number');
|
||||||
|
assert.equal(h.b.killed, killedBefore + 1, '`killed` counts it (master semantics unchanged)');
|
||||||
|
|
||||||
|
assert.ok(h.runUntil(() => !!runtime(), 120), 'a second summon');
|
||||||
|
const beforeLeak = capsule(h.b);
|
||||||
|
const leakedBefore = h.b.leakedCount;
|
||||||
|
h.b.leak(runtime());
|
||||||
|
assert.deepEqual(capsule(h.b), beforeLeak, 'a leaked boss summon moves no capsule number');
|
||||||
|
assert.equal(h.b.leakedCount, leakedBefore + 1, 'a counted leak — the LP charge follows the real data (`counted`), as master');
|
||||||
|
|
||||||
|
// the leader itself: tag 'boss' ⇒ counted false ⇒ in no capsule number, and killing it is no `killed` either (master)
|
||||||
|
const killedBeforeLeader = h.b.killed;
|
||||||
|
h.b.kill(leader, null);
|
||||||
|
assert.equal(h.b.killed, killedBeforeLeader, 'the leader is not a counted knock-out (master reading)');
|
||||||
|
// every one of the round's own enemies resolved ⇒ the capsule is full
|
||||||
|
for (const e of h.b.enemies.filter((e) => e.alive && e.inTotal)) h.b.kill(e, null);
|
||||||
|
for (const e of h.b.enemies.filter((e) => e.alive && e.inTotal)) h.b.leak(e);
|
||||||
|
assert.equal(h.b.killedInTotal + h.b.leakedInTotal, h.b.total);
|
||||||
|
assert.equal(h.b.resolved, h.b.total, 'Boss 死后 resolved === total — the summons never pushed the denominator up');
|
||||||
|
assert.equal(h.b.total, scheduled, 'nor did they move it at the end');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kept honest: a helper enemy record the tests build is a scheduled enemy (inTotal) while spawnEnemy is not', () => {
|
||||||
|
const h = makeBattle({
|
||||||
|
seed: 31, autoFinish: false, timeLimit: 60,
|
||||||
|
defs: { enemies: { enemy_helper: enemyRec({ key: 'enemy_helper', hp: 10, speed: 0 }) } },
|
||||||
|
enemies: [{ key: 'enemy_helper' }],
|
||||||
|
});
|
||||||
|
h.step();
|
||||||
|
assert.equal(h.b.total, 1);
|
||||||
|
assert.equal(h.b.enemies[0].inTotal, true);
|
||||||
|
const runtime = h.spawn('enemy_helper', { pos: [9, 4] });
|
||||||
|
assert.equal(h.b.total, 1, 'a runtime spawnEnemy is not in the denominator');
|
||||||
|
assert.ok(!runtime.inTotal && runtime.counted, '… and not in the capsule, while counted stays master\'s');
|
||||||
|
const explicit = h.b.spawnEnemy('enemy_helper', { pos: [9, 3], inTotal: true }); // the content back door
|
||||||
|
assert.equal(h.b.total, 2, 'an explicit `inTotal: true` is honoured');
|
||||||
|
assert.ok(explicit.inTotal);
|
||||||
|
assert.equal(h.b.enemies.filter((e) => e.alive).length, 3);
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Browser checks of the stage-device tip (GitHub #228, idea of PR #229 by @rickylxw; the terrain tip of #184 for devices) on
|
||||||
|
// the in-match mock harness (public/dev/game-mock.html):
|
||||||
|
// SP_E2E=1 CHROME_PATH=… node --test test/ui/device-tip.e2e.test.js
|
||||||
|
//
|
||||||
|
// A tap on a STAGE DEVICE standing on the board — 阻隔工事 here (the mock's default stage act2autochess_m01 keeps its
|
||||||
|
// blowers and its dormant turrets off the prep field, so ?stage=act1autochess_m01 boards crates within it) — opens the
|
||||||
|
// device's own card, on both field implementations: the engine canvas (render/app.js `tileClick`, reached by clicking
|
||||||
|
// the tile's own screen position) and the DOM fallback (ui/fallbackField.js, the tile div). Asserted: the card names
|
||||||
|
// the device and its mechanism, an ordinary tile closes it again, and no scenario logs a console error. The words and
|
||||||
|
// the numbers are unit-tested in test/ui/device-tip.test.js (deviceInfo / deviceTipAt, against every device of the real
|
||||||
|
// stages and a real battle) — this file is about the tap reaching them.
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Tiles of the boarded stage (act1autochess_m01) the tip must answer for, and one ordinary tile it must not. */
|
||||||
|
const CRATE_STAGE = 'act1autochess_m01';
|
||||||
|
const CRATE = { row: 10, col: 5, name: '阻隔工事' };
|
||||||
|
const FLOOR = { row: 12, col: 9, name: null };
|
||||||
|
|
||||||
|
describe('stage device tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let browser;
|
||||||
|
let base;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const { startServer } = await import('../../server/index.js');
|
||||||
|
const puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||||
|
base = `http://127.0.0.1:${srv.port}`;
|
||||||
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await srv?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: w, height: h });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||||
|
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
|
||||||
|
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
|
||||||
|
return { page, problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open detail card's text (null while none is open). */
|
||||||
|
const card = (page) => page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.dpanel');
|
||||||
|
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the fallback (DOM) board: a tap on the crate opens its card, an ordinary tile opens nothing', async () => {
|
||||||
|
const { page, problems } = await open(`/dev/game-mock.html?phase=PREP&render=fallback&stage=${CRATE_STAGE}`);
|
||||||
|
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||||
|
await sleep(400);
|
||||||
|
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||||
|
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||||
|
if (!el) return false;
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||||
|
return true;
|
||||||
|
}, [row, col]);
|
||||||
|
|
||||||
|
assert.ok(await tap(CRATE.row, CRATE.col), 'the mock board draws the crate tile');
|
||||||
|
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||||
|
const text = await card(page);
|
||||||
|
assert.match(text, new RegExp(CRATE.name), text);
|
||||||
|
assert.match(text, /场地装置/, text);
|
||||||
|
assert.match(text, /绕开它走/, text);
|
||||||
|
|
||||||
|
// an ordinary tile: nothing to explain, and the card that was open closes with the press
|
||||||
|
assert.ok(await tap(FLOOR.row, FLOOR.col));
|
||||||
|
await sleep(200);
|
||||||
|
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
|
||||||
|
const { page, problems } = await open(`/dev/game-mock.html?phase=PREP&stage=${CRATE_STAGE}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||||
|
await sleep(1200); // models / camera
|
||||||
|
const at = (row, col) => page.evaluate(([r, c]) => {
|
||||||
|
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||||
|
return t && { x: t.x, y: t.y };
|
||||||
|
}, [row, col]);
|
||||||
|
const pt = await at(CRATE.row, CRATE.col);
|
||||||
|
assert.ok(pt, 'the crate tile has a screen position');
|
||||||
|
await page.mouse.click(pt.x, pt.y);
|
||||||
|
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||||
|
const text = await card(page);
|
||||||
|
assert.match(text, new RegExp(CRATE.name), text);
|
||||||
|
assert.match(text, /绕开它走/, text);
|
||||||
|
|
||||||
|
const floor = await at(FLOOR.row, FLOOR.col);
|
||||||
|
await page.mouse.click(floor.x, floor.y);
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await card(page), null, 'the floor tile closes it again');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A battle: the crates are device UNITS that come in with 'spawn' events after the field meta (the mock's ?variant=devices
|
||||||
|
// streams them like the sim). The tap finds the unit, the card draws its live HP, and it closes when the crate is destroyed.
|
||||||
|
const hpText = (page) => page.evaluate(() => document.querySelector('.dpanel .dhp')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
|
||||||
|
|
||||||
|
test('a battle on the fallback board: the crate unit\'s card shows its live HP and closes when it is destroyed', async () => {
|
||||||
|
const { page, problems } = await open(`/dev/game-mock.html?phase=COMBAT&variant=devices&render=fallback&stage=${CRATE_STAGE}`);
|
||||||
|
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||||
|
await page.waitForFunction(() => globalThis.__MOCK__?.S()?.battle?.devices?.length > 0, { timeout: 10000 });
|
||||||
|
await sleep(600); // the spawn events and the first snapshots
|
||||||
|
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||||
|
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||||
|
if (!el) return false;
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||||
|
return true;
|
||||||
|
}, [row, col]);
|
||||||
|
const unit = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[0]; return { row: d.y, col: d.x }; });
|
||||||
|
assert.ok(await tap(unit.row, unit.col), 'the board draws the crate unit\'s tile');
|
||||||
|
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||||
|
const text = await card(page);
|
||||||
|
assert.match(text, new RegExp(CRATE.name), text);
|
||||||
|
assert.match(text, /绕开它走/, text);
|
||||||
|
assert.equal(await hpText(page), '100 / 100');
|
||||||
|
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 40)));
|
||||||
|
await page.waitForFunction(() => /60 \/ 100/.test(document.querySelector('.dpanel .dhp')?.textContent || ''), { timeout: 5000 });
|
||||||
|
// destroyed: its HP bar runs out, then its card closes with it (the tuple leaves the snapshots)
|
||||||
|
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 100)));
|
||||||
|
await page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 5000 });
|
||||||
|
// a destroyed crate explains nothing any more: the tap falls through to the (ordinary) tile under it
|
||||||
|
await sleep(1500);
|
||||||
|
assert.ok(await tap(unit.row, unit.col));
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await card(page), null, 'the wreck opens nothing');
|
||||||
|
// another crate still answers
|
||||||
|
const other = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[1]; return { row: d.y, col: d.x }; });
|
||||||
|
assert.ok(await tap(other.row, other.col));
|
||||||
|
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a battle on the engine canvas: a tap on the crate unit\'s tile opens its card with the HP bar', async () => {
|
||||||
|
const { page, problems } = await open(`/dev/game-mock.html?phase=COMBAT&variant=devices&render=engine&stage=${CRATE_STAGE}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||||
|
await page.waitForFunction(() => globalThis.__MOCK__?.S()?.battle?.devices?.length > 0, { timeout: 10000 });
|
||||||
|
await sleep(2500); // models / camera / the spawn events
|
||||||
|
const unit = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[0]; return { row: d.y, col: d.x }; });
|
||||||
|
const at = () => page.evaluate(([r, c]) => {
|
||||||
|
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||||
|
return t && { x: t.x, y: t.y };
|
||||||
|
}, [unit.row, unit.col]);
|
||||||
|
let text = null;
|
||||||
|
for (let i = 0; i < 3 && text === null; i++) {
|
||||||
|
const p = await at();
|
||||||
|
assert.ok(p, 'the crate tile has a screen position');
|
||||||
|
await page.mouse.click(p.x, p.y);
|
||||||
|
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; }
|
||||||
|
text = await card(page);
|
||||||
|
}
|
||||||
|
assert.ok(text !== null, 'the tap opened a card');
|
||||||
|
assert.match(text, new RegExp(CRATE.name), text);
|
||||||
|
assert.equal(await hpText(page), '100 / 100');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// GitHub #228 (idea of PR #229 by @rickylxw — the terrain tip of #184, for the stage DEVICES): a tap on a stage device
|
||||||
|
// (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置) explains it. `screens/game.js` tileClick hands over `{ kind: 'device',
|
||||||
|
// device }`, resolved from the stage the board on screen is built from — `gameLogic.deviceInfo` (the prep) or
|
||||||
|
// `deviceTipAt` (a battle: the crate / turret is a device unit there) — and the panel opens its device card.
|
||||||
|
// Covered: what the card says for the devices of the real stages (the sim's own numbers — sim/content/devices.js) in every
|
||||||
|
// shipped language, what the screen's override pipeline does to it (a 机变 card removes the crate → no tip), the battle
|
||||||
|
// path against a REAL battle (the turret outside the rect stands on another tile than its stage pos; the stage crates and
|
||||||
|
// turrets are not in the field meta of a battle shown from its start; a destroyed crate is gone), the panel link
|
||||||
|
// (resolveDetail) and that the card closes on the next field press. The tap reaching the screen is test/ui/device-tip.e2e.test.js.
|
||||||
|
|
||||||
|
import { test, afterEach } 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 { setLang, setMessages, registerLangs } from '../../shared/i18n.js';
|
||||||
|
import { makeBattle } from '../helpers/battleHarness.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||||
|
const { deviceInfo, deviceTipAt, noteDeviceUnits, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
|
||||||
|
|
||||||
|
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
|
||||||
|
const pack = (code) => { const p = JSON.parse(readFileSync(path.join(ROOT, 'public', 'i18n', `${code}.json`), 'utf8')); delete p._meta; return p; };
|
||||||
|
|
||||||
|
afterEach(() => { setLang('zh'); for (const l of ['en', 'ja', 'ko', 'zh-TW']) setMessages(l, {}); });
|
||||||
|
|
||||||
|
/** The first device of `role` in the stage. */
|
||||||
|
function deviceOf(stage, role) {
|
||||||
|
const d = (stage.devices || []).find((x) => x.role === role);
|
||||||
|
if (!d) throw new Error(`no ${role} device in ${stage.id}`);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
const TURRET_ON = { 'trap_1104_aclasert#1': true };
|
||||||
|
|
||||||
|
test('阻隔工事: the crate says its own HP and the enemy behaviour the sim runs', () => {
|
||||||
|
const st = stages['act1autochess_m01'];
|
||||||
|
const d = deviceOf(st, 'crate');
|
||||||
|
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||||
|
assert.equal(info.key, 'crate');
|
||||||
|
assert.equal(info.name, '阻隔工事', 'the device\'s own data name (the data overlay localizes it)');
|
||||||
|
assert.equal(info.tag, '场地装置');
|
||||||
|
assert.deepEqual([info.row, info.col], d.pos);
|
||||||
|
assert.match(info.lines[0], /绕开它走/);
|
||||||
|
assert.match(info.lines[0], /无路可走/);
|
||||||
|
assert.equal(info.lines[1], '生命 100 点,被摧毁后从场上消失');
|
||||||
|
assert.deepEqual(info.facts, ['不可部署']);
|
||||||
|
assert.equal(info.stats, undefined, 'the crate needs no stat grid beyond its HP line');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('“双眼皮”: the turret reads its stats and the bond-layer scaling from the device entry', () => {
|
||||||
|
const st = stages['act1autochess_m01'];
|
||||||
|
// every stage's turrets stand dormant (active: false) until 机械援助 or a 机变 card turns the alias on — the screen
|
||||||
|
// passes the player's own effectiveStage, so the tip lights up exactly when the client draws the turret
|
||||||
|
const d = deviceOf(st, 'turret');
|
||||||
|
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]), null, 'dormant: no tip');
|
||||||
|
const info = deviceInfo(effectiveStage(st, { deviceOverrides: TURRET_ON }), d.pos[0], d.pos[1]);
|
||||||
|
assert.equal(info.name, '“双眼皮”');
|
||||||
|
assert.match(info.lines[0], /法术伤害/);
|
||||||
|
assert.match(info.lines[0], /脆弱/);
|
||||||
|
assert.equal(info.lines[1], '按当前层数最高的盟约计算:每 1 层,攻击速度 +1(至多 +300),附加的脆弱 +0.1%(至多 +30%)');
|
||||||
|
assert.equal(info.lines[2], '脆弱持续 2 秒');
|
||||||
|
// the same numbers the sim runs (devices.js spawnTurret reads these blackboard keys)
|
||||||
|
const bb = d.skill.bb;
|
||||||
|
assert.deepEqual([bb.attack_speed_per_stack, bb.max_attack_speed, bb.damage_scale_per_stack, bb.max_damage_scale], [1, 300, 0.001, 1.3]);
|
||||||
|
assert.deepEqual(info.stats, [
|
||||||
|
{ k: '生命上限', v: 3000 }, { k: '攻击', v: 900 }, { k: '防御', v: 200 }, { k: '攻击间隔', v: '4s' },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(info.facts, ['不可部署']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('射击台: the platform tells the deploy class it feeds the deploy map', () => {
|
||||||
|
const st = stages['act1autochess_m03'];
|
||||||
|
const d = deviceOf(st, 'platform');
|
||||||
|
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||||
|
assert.equal(info.name, '射击台');
|
||||||
|
assert.match(info.lines[0], /高台位/);
|
||||||
|
assert.match(info.lines[1], /远程位干员可以部署/);
|
||||||
|
assert.match(info.lines[1], /不阻挡敌人/);
|
||||||
|
assert.deepEqual(info.facts, ['仅远程位可部署']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('源石流发生装置: the blower names its direction, its reach and the airflow numbers the sim applies', () => {
|
||||||
|
const st = stages['act2autochess_m01'];
|
||||||
|
const d = deviceOf(st, 'blower');
|
||||||
|
assert.equal(d.dir, 'DOWN');
|
||||||
|
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||||
|
assert.equal(info.name, '源石流发生装置');
|
||||||
|
assert.equal(info.lines[0], '向前方 3 格吹出气流(这一台朝下)');
|
||||||
|
assert.equal(info.lines[1], '部署在气流里的干员:朝向与风向相同的攻击力 +30%、相反的 −30%');
|
||||||
|
assert.equal(info.lines[2], '在气流里移动的敌人:顺风移动速度 ×1.5、逆风 ×0.5');
|
||||||
|
// …which are the blackboard values devices.js buildTerrain / tickAirflowAlly / tickAirflowEnemy read
|
||||||
|
const bb = d.skill.bb;
|
||||||
|
assert.deepEqual([bb['blower_s_character[equal].atk'], bb['blower_s_character[opposite].atk'], bb['blower_s_enemy[equal].move_speed'], bb['blower_s_enemy[opposite].move_speed']], [0.3, -0.3, 0.5, -0.5]);
|
||||||
|
// a blower without its own blackboard falls back to the stage's special.blower.bb (the sim's order)
|
||||||
|
const bare = { ...st, devices: [{ ...d, skill: null }], special: { blower: { bb: { 'blower_s_character[equal].atk': 0.1, 'blower_s_enemy[opposite].move_speed': -0.25 } } } };
|
||||||
|
const fb = deviceInfo(bare, d.pos[0], d.pos[1]);
|
||||||
|
assert.equal(fb.lines[1], '部署在气流里的干员:朝向与风向相同的攻击力 +10%');
|
||||||
|
assert.equal(fb.lines[2], '在气流里移动的敌人:逆风 ×0.75');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a device the client does not draw says nothing: inactive, card-removed, tip-less role, ordinary tile', () => {
|
||||||
|
// act1 m02's platforms stand dormant (weight-0 cards add them) — not drawn, so no tip
|
||||||
|
const m02 = stages['act1autochess_m02'];
|
||||||
|
const p = deviceOf(m02, 'platform');
|
||||||
|
assert.equal(deviceInfo(m02, p.pos[0], p.pos[1]), null);
|
||||||
|
// the screen passes the player's own effectiveStage: a 机变 card's deviceOverride flips the crate's active off
|
||||||
|
const fake = { rows: [], tiles: {}, devices: [{ key: 'trap_1105_accrate', alias: 'trap_1105_accrate#001', role: 'crate', pos: [3, 3], active: true, stats: { maxHp: 100 } }] };
|
||||||
|
assert.ok(deviceInfo(fake, 3, 3), 'the crate stands');
|
||||||
|
const removed = effectiveStage(fake, { deviceOverrides: { 'trap_1105_accrate#001': false } });
|
||||||
|
assert.equal(deviceInfo(removed, 3, 3), null);
|
||||||
|
// a tip-less role (盟约寒风 is invisible; the controllers have no tile) falls through like an ordinary tile
|
||||||
|
const m01 = stages['act1autochess_m01'];
|
||||||
|
const cw = deviceOf(m01, 'coldWind');
|
||||||
|
assert.equal(deviceInfo(m01, cw.pos[0], cw.pos[1]), null);
|
||||||
|
assert.equal(deviceInfo(m01, 5, 5), null);
|
||||||
|
assert.equal(deviceInfo(null, 0, 0), null);
|
||||||
|
assert.equal(deviceInfo({ devices: 'x' }, 0, 0), null);
|
||||||
|
assert.equal(deviceInfo(m01, 1.5, 2), null);
|
||||||
|
assert.equal(deviceInfo({ devices: [{ role: '__proto__', pos: [1, 1], active: true }, { role: 'constructor', pos: [1, 1], active: true }] }, 1, 1), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every drawn tip-able device of every active stage carries a well-formed card, in every language', () => {
|
||||||
|
for (const code of ['en', 'ja', 'ko', 'zh-TW']) setMessages(code, pack(code));
|
||||||
|
registerLangs([{ code: 'en' }, { code: 'ja', fallback: ['en'] }, { code: 'ko', fallback: ['en'] }, { code: 'zh-TW' }]);
|
||||||
|
let n = 0;
|
||||||
|
for (const lang of ['zh', 'en', 'ja', 'ko', 'zh-TW']) {
|
||||||
|
setLang(lang);
|
||||||
|
for (const st of Object.values(stages)) {
|
||||||
|
if (!st.active) continue;
|
||||||
|
for (const d of st.devices || []) {
|
||||||
|
if (!['crate', 'turret', 'platform', 'blower'].includes(d.role)) continue;
|
||||||
|
if (!(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
|
||||||
|
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||||
|
assert.ok(info, `${st.id} ${d.role} @${d.pos} has a tip`);
|
||||||
|
assert.ok(info.name && info.tag, `${st.id} ${d.role} has a name and a tag`);
|
||||||
|
assert.ok(info.lines.length && info.facts.length, `${st.id} ${d.role} has mechanism lines and a tile fact`);
|
||||||
|
for (const line of [...info.lines, ...info.facts, info.tag]) {
|
||||||
|
assert.ok(typeof line === 'string' && line.length && !/[{}]/.test(line), `${lang} ${st.id} ${d.role}: "${line}"`);
|
||||||
|
// nothing is left in the source language where the pack has the words (the Chinese sources are Han text)
|
||||||
|
if (lang === 'en' || lang === 'ko') assert.ok(!/[一-鿿]/.test(line), `${lang}: "${line}" is untranslated`);
|
||||||
|
}
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(n >= 5 * 4, `checked ${n} cards`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the words of a card follow the language: English, 日本語, 한국어, 繁體中文', () => {
|
||||||
|
for (const code of ['en', 'ja', 'ko', 'zh-TW']) setMessages(code, pack(code));
|
||||||
|
registerLangs([{ code: 'en' }, { code: 'ja', fallback: ['en'] }, { code: 'ko', fallback: ['en'] }, { code: 'zh-TW' }]);
|
||||||
|
const st = stages['act2autochess_m01'];
|
||||||
|
const d = deviceOf(st, 'blower');
|
||||||
|
setLang('en');
|
||||||
|
const en = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||||
|
assert.equal(en.tag, 'Stage Device');
|
||||||
|
assert.equal(en.lines[0], 'Blows an airflow 3 tiles ahead (this one faces Down)');
|
||||||
|
assert.equal(en.lines[1], 'Operators deployed in the airflow: ATK +30% when facing the same way as the airflow, −30% when facing the opposite way');
|
||||||
|
assert.equal(en.facts[0], 'Not deployable');
|
||||||
|
setLang('ja');
|
||||||
|
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[1], '気流内に配置されたオペレーター:気流と同じ向きの攻撃力+30%、逆向きは−30%');
|
||||||
|
setLang('ko');
|
||||||
|
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[0], '전방 3칸으로 기류를 내뿜음(이 장치는 아래 방향)');
|
||||||
|
setLang('zh-TW');
|
||||||
|
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[2], '在氣流裡移動的敵人:順風移動速度 ×1.5、逆風 ×0.5');
|
||||||
|
const tur = effectiveStage(stages['act1autochess_m01'], { deviceOverrides: TURRET_ON });
|
||||||
|
const t0 = deviceOf(tur, 'turret');
|
||||||
|
setLang('en');
|
||||||
|
const eTur = deviceInfo(tur, t0.pos[0], t0.pos[1]);
|
||||||
|
assert.deepEqual(eTur.stats.map((s) => s.k), ['Max HP', 'ATK', 'DEF', 'Attack Interval']);
|
||||||
|
assert.equal(eTur.lines[2], 'The Fragile lasts 2 seconds');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- a battle on screen ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What the game screen holds in a battle: the device UnitInfos (meta + 'spawn' events) and the latest snapshot by unit id. */
|
||||||
|
function screenView(h) {
|
||||||
|
const units = noteDeviceUnits(new Map(), { units: h.battle.fieldMeta().units, events: h.eventsOf('spawn') });
|
||||||
|
const snap = new Map(h.snapshot().units.map((t) => [t[0], t]));
|
||||||
|
return { units: [...units.values()], snap };
|
||||||
|
}
|
||||||
|
const turretPlayers = () => [{
|
||||||
|
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 10 } },
|
||||||
|
playerEffects: [{ id: 'aceffect_band_43', key: 'auto_chess_change_map', params: TURRET_ON }],
|
||||||
|
}];
|
||||||
|
|
||||||
|
test('a battle: the crates and the turret are found by their UNIT — the field meta of a battle shown from its start holds none of them', () => {
|
||||||
|
const stageId = 'act1autochess_m01';
|
||||||
|
const h = makeBattle({ stageId, players: turretPlayers(), autoFinish: false, timeLimit: 30 });
|
||||||
|
assert.deepEqual(noteDeviceUnits(new Map(), { units: h.battle.fieldMeta().units }).size, 0, 'nothing stands before the first step');
|
||||||
|
h.step(1);
|
||||||
|
const v = screenView(h);
|
||||||
|
assert.equal(v.units.length, 4, '3 crates + the turret');
|
||||||
|
const stage = stages[stageId];
|
||||||
|
const crate = v.units.find((u) => u.defId === 'trap_1105_accrate');
|
||||||
|
const tip = deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), v);
|
||||||
|
assert.equal(tip.key, 'crate');
|
||||||
|
assert.equal(tip.unitId, crate.id, 'the unit rides along: the screen reads its live HP through it');
|
||||||
|
assert.equal(tip.lines[1], '生命 100 点,被摧毁后从场上消失');
|
||||||
|
// the turret stands on its stage pos here (inside the rect); the stage entry is dormant in the plain stage a watcher holds
|
||||||
|
// (a teammate's 机变 / band overrides are not known) — the unit proves it stands, so it is explained
|
||||||
|
const turret = v.units.find((u) => u.defId === 'trap_1104_aclasert');
|
||||||
|
const tt = deviceTipAt(stage, Math.round(turret.y), Math.round(turret.x), v);
|
||||||
|
assert.equal(tt.key, 'turret');
|
||||||
|
assert.equal(tt.name, '“双眼皮”');
|
||||||
|
assert.equal(tt.stats[0].v, 3000);
|
||||||
|
// an ordinary tile, and a tile outside every unit: nothing
|
||||||
|
assert.equal(deviceTipAt(stage, 12, 8, v), null);
|
||||||
|
// before the first snapshot of the battle has arrived, a unit counts as standing
|
||||||
|
assert.ok(deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), { units: v.units, snap: new Map() }));
|
||||||
|
assert.ok(deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), { units: v.units }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a battle: a turret outside the rect answers on the tile its UNIT stands on, not on its stage pos', () => {
|
||||||
|
const stageId = 'act2autochess_m01';
|
||||||
|
const h = makeBattle({ stageId, players: turretPlayers(), autoFinish: false, timeLimit: 30 });
|
||||||
|
h.step(1);
|
||||||
|
const v = screenView(h);
|
||||||
|
const stage = stages[stageId];
|
||||||
|
const turret = v.units.find((u) => u.defId === 'trap_1104_aclasert');
|
||||||
|
assert.ok(turret, 'the turret is online');
|
||||||
|
const [r, c] = [Math.round(turret.y), Math.round(turret.x)];
|
||||||
|
const pos = deviceOf(effectiveStage(stage, { deviceOverrides: TURRET_ON }), 'turret').pos;
|
||||||
|
assert.notDeepEqual([r, c], pos, 'act2 m01\'s turret stands on row 8, outside the normal field: the sim moves it');
|
||||||
|
assert.equal(deviceTipAt(stage, r, c, v).key, 'turret');
|
||||||
|
assert.equal(deviceTipAt(stage, r, c, v).unitId, turret.id);
|
||||||
|
// its stage pos holds nothing any more (the static device is not drawn inside the rect): no phantom card there
|
||||||
|
assert.equal(deviceTipAt(effectiveStage(stage, { deviceOverrides: TURRET_ON }), pos[0], pos[1], v), null);
|
||||||
|
// the blowers are no units: they answer by their stage pos, as in the prep
|
||||||
|
const bl = deviceOf(stage, 'blower');
|
||||||
|
assert.equal(deviceTipAt(stage, bl.pos[0], bl.pos[1], v).key, 'blower');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a battle: a destroyed crate is gone — HP gone, then its tuple leaves the snapshots — and the tap falls through to the tile', () => {
|
||||||
|
const h = makeBattle({ stageId: 'act1autochess_m01', autoFinish: false, timeLimit: 30 });
|
||||||
|
h.step(1);
|
||||||
|
const stage = stages['act1autochess_m01'];
|
||||||
|
const crate = h.battle.allyUnits.find((u) => u.defId === 'trap_1105_accrate');
|
||||||
|
const [r, c] = [crate.tileR, crate.tileC];
|
||||||
|
assert.ok(deviceTipAt(stage, r, c, screenView(h)), 'standing');
|
||||||
|
h.battle.kill(crate, null);
|
||||||
|
h.step(1);
|
||||||
|
assert.equal(crate.alive, false);
|
||||||
|
const dying = screenView(h);
|
||||||
|
assert.equal(dying.snap.get(crate.id)[3], 0, 'still in the snapshots for the die animation, with no HP');
|
||||||
|
assert.equal(deviceTipAt(stage, r, c, dying), null);
|
||||||
|
h.run(3);
|
||||||
|
const gone = screenView(h);
|
||||||
|
assert.equal(gone.snap.has(crate.id), false, 'its tuple left the snapshots');
|
||||||
|
assert.ok(gone.snap.size > 0);
|
||||||
|
assert.equal(deviceTipAt(stage, r, c, gone), null, 'long destroyed: the unit is still in the meta, the card is not');
|
||||||
|
// the other crates stand
|
||||||
|
const other = h.battle.allyUnits.find((u) => u.defId === 'trap_1105_accrate' && u.alive);
|
||||||
|
assert.ok(deviceTipAt(stage, other.tileR, other.tileC, gone));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the prep and a scouted prep board: the stage\'s own device entries answer by pos (deviceInfo)', () => {
|
||||||
|
const st = stages['act1autochess_m01'];
|
||||||
|
const d = deviceOf(st, 'crate');
|
||||||
|
assert.ok(deviceInfo(st, d.pos[0], d.pos[1]));
|
||||||
|
// noteDeviceUnits keeps only device units, from the meta and from 'spawn' events
|
||||||
|
const m = noteDeviceUnits(new Map(), {
|
||||||
|
units: [{ id: 1, kind: 'op' }, { id: 2, kind: 'device', defId: 'trap_1105_accrate' }, null],
|
||||||
|
events: [['spawn', { id: 3, kind: 'device', defId: 'trap_1104_aclasert' }], ['spawn', { id: 4, kind: 'enemy' }], ['die', 2], null, 'x'],
|
||||||
|
});
|
||||||
|
assert.deepEqual([...m.keys()], [2, 3]);
|
||||||
|
assert.equal(noteDeviceUnits(new Map(), {}).size, 0);
|
||||||
|
assert.equal(noteDeviceUnits(new Map(), { units: 'x', events: 5 }).size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a device target resolves into the panel card; a malformed one resolves to nothing; the card closes on a field press', () => {
|
||||||
|
const st = stages['act1autochess_m01'];
|
||||||
|
const d = deviceOf(st, 'crate');
|
||||||
|
const info = deviceInfo(st, d.pos[0], d.pos[1]);
|
||||||
|
assert.deepEqual(resolveDetail({ kind: 'device', device: info }, new Map()), { type: 'device', device: info });
|
||||||
|
const unit = { ...info, unitId: 17 };
|
||||||
|
assert.deepEqual(resolveDetail({ kind: 'device', device: unit }, new Map()), { type: 'device', device: unit, unitId: 17 },
|
||||||
|
'a battle device carries its unit id: the screen reads the live HP through it');
|
||||||
|
assert.equal(resolveDetail({ kind: 'device' }, new Map()), null);
|
||||||
|
assert.equal(resolveDetail({ kind: 'device', device: '阻隔工事' }, new Map()), null);
|
||||||
|
assert.equal(resolveDetail(null, new Map()), null);
|
||||||
|
assert.equal(closesOnFieldPress({ kind: 'device', device: info }), true, 'the next field press closes the device card');
|
||||||
|
assert.equal(closesOnFieldPress({ kind: 'terrain' }), true);
|
||||||
|
assert.equal(closesOnFieldPress({ kind: 'enemy' }), false);
|
||||||
|
});
|
||||||
@@ -635,7 +635,10 @@ describe('enemies, HUD, stats', () => {
|
|||||||
assert.deepEqual(factionTypes(['FLY', { type: 'TIMES' }, { id: 'SPECIAL' }, 'FLY', 3, null]), ['FLY', 'TIMES', 'SPECIAL']);
|
assert.deepEqual(factionTypes(['FLY', { type: 'TIMES' }, { id: 'SPECIAL' }, 'FLY', 3, null]), ['FLY', 'TIMES', 'SPECIAL']);
|
||||||
});
|
});
|
||||||
test('snapHud / bossFrac', () => {
|
test('snapHud / bossFrac', () => {
|
||||||
assert.deepEqual(snapHud({ killed: 3, total: 9, dp: 20 }), { killed: 3, total: 9, dp: 20, boss: null });
|
assert.deepEqual(snapHud({ killed: 3, total: 9, dp: 20 }), { killed: 3, resolved: 3, total: 9, dp: 20, boss: null });
|
||||||
|
// the capsule's numerator: the field's own enemies knocked out or leaked (a runtime split / summon knocked down
|
||||||
|
// counts in `killed` only, so the two may differ — 5/9 down but 3/9 of the round's own list resolved)
|
||||||
|
assert.deepEqual(snapHud({ killed: 5, resolved: 3, total: 9, dp: 20 }), { killed: 5, resolved: 3, total: 9, dp: 20, boss: null });
|
||||||
assert.deepEqual(snapHud({ boss: { hp: 50, max: 200 } }).boss, { hp: 50, max: 200 });
|
assert.deepEqual(snapHud({ boss: { hp: 50, max: 200 } }).boss, { hp: 50, max: 200 });
|
||||||
assert.equal(snapHud(null), null);
|
assert.equal(snapHud(null), null);
|
||||||
assert.equal(bossFrac({ hp: 50, max: 200 }), 0.25);
|
assert.equal(bossFrac({ hp: 50, max: 200 }), 0.25);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Browser check of the shop bar's fold on 准备就绪 (GitHub #138; opt-in, ~10 s) on the in-match mock harness:
|
||||||
|
// SP_E2E=1 CHROME_PATH=… node --test test/ui/ready-fold.e2e.test.js
|
||||||
|
//
|
||||||
|
// In prep, 准备就绪 folds the shop bar (the 展开商店 tab, the screen's `is-collapsed`), 取消准备 unfolds it again; a fold made
|
||||||
|
// by hand before the press is not undone by it going the other way round (the player folds, readies, cancels: unfolded
|
||||||
|
// — the owner's wording is 「取消准备时再展开」); the bar can still be unfolded by hand while ready. The decision itself
|
||||||
|
// (only a change inside one prep round counts) is unit-tested in test/ui/ready-fold.test.js.
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
describe('the shop bar folds on 准备就绪', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let browser;
|
||||||
|
let base;
|
||||||
|
before(async () => {
|
||||||
|
const { startServer } = await import('../../server/index.js');
|
||||||
|
const puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||||
|
base = `http://127.0.0.1:${srv.port}`;
|
||||||
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await srv?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = (page) => page.evaluate(() => ({
|
||||||
|
folded: !!document.querySelector('.shopbar-tab'),
|
||||||
|
cls: document.querySelector('.gm')?.classList.contains('is-collapsed') ?? null,
|
||||||
|
ready: document.querySelector('.readybtn')?.getAttribute('aria-pressed') === 'true',
|
||||||
|
label: document.querySelector('.readybtn__label')?.textContent ?? null,
|
||||||
|
}));
|
||||||
|
const press = (page, sel) => page.evaluate((s) => document.querySelector(s)?.click(), sel);
|
||||||
|
/** 准备就绪 with funds left asks first (gameLogic readyFundsPrompt): answer it; 取消准备 asks nothing. */
|
||||||
|
const ready = async (page) => {
|
||||||
|
await press(page, '.readybtn');
|
||||||
|
await sleep(250);
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /准备就绪/.test(b.textContent))?.click());
|
||||||
|
};
|
||||||
|
|
||||||
|
test('ready folds the shop bar, cancelling unfolds it; the tab still unfolds it by hand', async () => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1600, height: 900 });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.waitForSelector('.readybtn', { timeout: 15000 });
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
let s = await state(page);
|
||||||
|
assert.deepEqual([s.folded, s.cls, s.ready], [false, false, false], 'the shop starts open');
|
||||||
|
await ready(page);
|
||||||
|
await page.waitForSelector('.shopbar-tab', { timeout: 5000 });
|
||||||
|
s = await state(page);
|
||||||
|
assert.deepEqual([s.folded, s.cls, s.ready, s.label], [true, true, true, '取消准备'], '准备就绪 folded the shop bar');
|
||||||
|
|
||||||
|
// the player unfolds by hand while ready: it stays as they left it
|
||||||
|
await press(page, '.shopbar-tab__btn');
|
||||||
|
await sleep(300);
|
||||||
|
s = await state(page);
|
||||||
|
assert.deepEqual([s.folded, s.ready], [false, true]);
|
||||||
|
|
||||||
|
await ready(page); // 取消准备 with the bar already open: it stays open
|
||||||
|
await sleep(300);
|
||||||
|
s = await state(page);
|
||||||
|
assert.deepEqual([s.folded, s.ready], [false, false]);
|
||||||
|
|
||||||
|
// fold by hand, ready, cancel: unfolded again (「取消准备时再展开」)
|
||||||
|
await press(page, '.funds__collapse');
|
||||||
|
await page.waitForSelector('.shopbar-tab', { timeout: 5000 });
|
||||||
|
await ready(page);
|
||||||
|
await sleep(400);
|
||||||
|
s = await state(page);
|
||||||
|
assert.deepEqual([s.folded, s.ready], [true, true], 'still folded after the press');
|
||||||
|
await ready(page);
|
||||||
|
await sleep(400);
|
||||||
|
s = await state(page);
|
||||||
|
assert.deepEqual([s.folded, s.ready], [false, false], 'cancelling unfolds it');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// GitHub #138 (the owner's decision of 2026-10-07: 「点准备后商店自动收起,取消准备时再展开」): pressing 准备就绪 folds the shop
|
||||||
|
// bar in prep, cancelling the ready unfolds it. The decision is gameLogic readyShopFold(prev, cur) over the own CONFIRMED
|
||||||
|
// ready state (m.private.ready): only a change inside one prep round counts. The screen's effect applies it
|
||||||
|
// (screens/game.js; the browser check is test/ui/ready-fold.e2e.test.js).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const { readyShopFold } = await import('../../public/js/ui/gameLogic.js');
|
||||||
|
|
||||||
|
test('readyShopFold: ready folds, cancelling unfolds — inside one prep round', () => {
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 6, ready: true }), 'fold');
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 6, ready: false }), 'unfold');
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 6, ready: false }), null, 'nothing changed');
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 6, ready: true }), null, 'nothing changed');
|
||||||
|
// a truthy / falsy flag counts like a boolean (m.private carries true / false, a missing one is not ready)
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: undefined }, { round: 6, ready: 1 }), 'fold');
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: 1 }, { round: 6, ready: 0 }), 'unfold');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readyShopFold: the first state seen, a new round\'s reset and anything outside a prep are no press', () => {
|
||||||
|
// a reconnect in the middle of a prep (or the prep's start): no state before it
|
||||||
|
assert.equal(readyShopFold(null, { round: 6, ready: true }), null);
|
||||||
|
assert.equal(readyShopFold(undefined, { round: 6, ready: false }), null);
|
||||||
|
// the next round starts not ready (the server resets it): not a 取消准备
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 7, ready: false }), null);
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 7, ready: true }), null, 'a new round never reads as a press either');
|
||||||
|
// leaving the prep (combat, the draft, the observers): the screen passes null
|
||||||
|
assert.equal(readyShopFold({ round: 6, ready: true }, null), null);
|
||||||
|
assert.equal(readyShopFold(null, null), null);
|
||||||
|
assert.equal(readyShopFold('x', { round: 6, ready: true }), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Browser check of the settings entry of the lobby and the room (GitHub #238 — 设置 was reachable only from the title
|
||||||
|
// screen and a running match; opt-in, ~15 s):
|
||||||
|
// SP_E2E=1 CHROME_PATH=… node --test test/ui/settings-entry.e2e.test.js
|
||||||
|
//
|
||||||
|
// After 开始 the lobby's top bar carries a 设置 button left of 玩法说明 (ui/settings.js SettingsButton), and so does the
|
||||||
|
// room's after 创建同盟; each opens the very settings modal of the title screen (language, sound, 快捷键 …) and closes
|
||||||
|
// with Esc or 完成, leaving the screen as it was. No console / page errors. The modal itself and its contents are covered
|
||||||
|
// by the title screen's tests; this file is about the entry. Not in the modal, deliberately: background music in a
|
||||||
|
// hidden tab and the board style (the owner's decision of 2026-10-07 on #238).
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let browser;
|
||||||
|
let base;
|
||||||
|
before(async () => {
|
||||||
|
const { startServer } = await import('../../server/index.js');
|
||||||
|
const puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||||
|
base = `http://127.0.0.1:${srv.port}`;
|
||||||
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await srv?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Is the 设置 button the sibling right before the 玩法说明 button of `screen`'s top bar? */
|
||||||
|
const settingsBeforeGuide = (page, screen, guideClass) => page.evaluate((s, g) => {
|
||||||
|
const btn = document.querySelector(`${s} [data-testid="settings-btn"]`);
|
||||||
|
const guide = document.querySelector(`${s} .${g}`);
|
||||||
|
return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent) && btn.querySelector('svg.btn__icon') != null;
|
||||||
|
}, screen, guideClass);
|
||||||
|
const modalText = (page) => page.evaluate(() => document.querySelector('.modal')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
|
||||||
|
|
||||||
|
test('the lobby: 设置 left of 玩法说明 opens the settings modal; Esc closes it; the room has the same entry', async () => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1600, height: 900 });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
await page.goto(`${base}/`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.waitForSelector('input', { timeout: 15000 });
|
||||||
|
await page.type('input', '测试博士');
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
|
||||||
|
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__right', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar');
|
||||||
|
assert.equal(await modalText(page), null, 'closed until asked');
|
||||||
|
await page.click('.lobby-screen [data-testid="settings-btn"]');
|
||||||
|
await page.waitForSelector('.modal', { timeout: 5000 });
|
||||||
|
const text = await modalText(page);
|
||||||
|
assert.match(text, /设置/);
|
||||||
|
assert.match(text, /背景音乐/);
|
||||||
|
assert.match(text, /快捷键/);
|
||||||
|
assert.doesNotMatch(text, /棋盘/, 'no board-style option (declined)');
|
||||||
|
assert.doesNotMatch(text, /后台/, 'no background-music option (declined)');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await sleep(300);
|
||||||
|
assert.equal(await modalText(page), null, 'Esc closes it');
|
||||||
|
assert.ok(await page.$('.lobby-screen'), 'the lobby is still there');
|
||||||
|
|
||||||
|
// the room: 创建同盟 → the same button, closed with 完成
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /创建同盟/.test(b.textContent))?.click());
|
||||||
|
await page.waitForSelector('.room-screen', { timeout: 15000 });
|
||||||
|
await sleep(500);
|
||||||
|
assert.ok(await settingsBeforeGuide(page, '.room-screen .topbar__left', 'room-guide'), '设置 sits right before 玩法说明 in the room\'s top bar');
|
||||||
|
await page.click('.room-screen [data-testid="settings-btn"]');
|
||||||
|
await page.waitForSelector('.modal', { timeout: 5000 });
|
||||||
|
assert.match(await modalText(page), /背景音乐/);
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /完成/.test(b.textContent))?.click());
|
||||||
|
await sleep(300);
|
||||||
|
assert.equal(await modalText(page), null, '完成 closes it');
|
||||||
|
assert.ok(await page.$('.room-screen'), 'the room is still there');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Browser check of the shop bar's empty slot (GitHub #332 / PR #333; opt-in, ~8 s) on the in-match mock harness:
|
||||||
|
// SP_E2E=1 CHROME_PATH=… node --test test/ui/shop-empty-slot.e2e.test.js
|
||||||
|
//
|
||||||
|
// A 调度中心 upgrade opens the new level's extra slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot
|
||||||
|
// in m.private shop.slots) and the next refresh or round start fills it. The bar used to draw every null slot like a
|
||||||
|
// bought one — the SOLD OUT frame with 「已招募」 — which on this slot says that somebody was recruited where nothing was.
|
||||||
|
// Asserted: the null slot is a bare frame (no text, a tooltip saying it fills at the next refresh), a bought slot still
|
||||||
|
// says 已招募, a real card is still a card, the empty frame is not a buyable card (`.scard:not(.scard--sold)` skips it)
|
||||||
|
// and a tap on it opens nothing; the refresh the mock answers fills it.
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
describe('the empty slot of the shop bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let browser;
|
||||||
|
let base;
|
||||||
|
before(async () => {
|
||||||
|
const { startServer } = await import('../../server/index.js');
|
||||||
|
const puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||||
|
base = `http://127.0.0.1:${srv.port}`;
|
||||||
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await srv?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cards = (page) => page.evaluate(() => [...document.querySelectorAll('.shopbar__cards .scard')].map((el) => ({
|
||||||
|
empty: el.classList.contains('scard--empty'), sold: el.classList.contains('scard--sold'), text: el.textContent.replace(/\s+/g, ' ').trim(),
|
||||||
|
title: el.getAttribute('title') || '',
|
||||||
|
})));
|
||||||
|
|
||||||
|
test('a null slot is a bare frame, a bought one still says 已招募, and a refresh fills the empty one', async () => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1600, height: 900 });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.waitForSelector('.shopbar__cards .scard', { timeout: 15000 });
|
||||||
|
await sleep(400);
|
||||||
|
const before = await cards(page);
|
||||||
|
const n = before.length;
|
||||||
|
assert.ok(n >= 4 && !before[0].sold, `the mock's shop has ${n} operator cards, the first one for sale`);
|
||||||
|
|
||||||
|
// the upgrade's new slot: a null at the end of the operator slots; one more slot bought
|
||||||
|
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
|
||||||
|
const slots = S.priv.shop.slots;
|
||||||
|
const chess = slots.filter((s) => !s || s.kind !== 'item');
|
||||||
|
const item = slots.filter((s) => s && s.kind === 'item');
|
||||||
|
chess[0] = { ...chess[0], sold: true };
|
||||||
|
S.priv.shop.slots = [...chess, null, ...item];
|
||||||
|
}));
|
||||||
|
await sleep(300);
|
||||||
|
const after = await cards(page);
|
||||||
|
assert.equal(after.length, n + 1);
|
||||||
|
const empty = after.filter((c) => c.empty);
|
||||||
|
assert.equal(empty.length, 1, 'one empty frame');
|
||||||
|
assert.equal(empty[0].text, '', 'no SOLD OUT, no 已招募 on it');
|
||||||
|
assert.match(empty[0].title, /空栏位/);
|
||||||
|
assert.ok(empty[0].sold, 'it carries the inert frame class every `:not(.scard--sold)` rule skips (no hover lift, not a buyable card)');
|
||||||
|
assert.equal(after[0].empty, false);
|
||||||
|
assert.match(after[0].text, /已招募/, 'a bought slot still says so');
|
||||||
|
assert.equal(after.filter((c) => !c.sold).length, before.filter((c) => !c.sold).length - 1, 'the rest are real cards');
|
||||||
|
assert.equal(after[after.length - 1].empty, true, 'the new slot is the last operator slot');
|
||||||
|
|
||||||
|
// a tap on the empty frame opens nothing
|
||||||
|
await page.evaluate(() => document.querySelector('.shopbar__cards .scard--empty').click());
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await page.$('.dpanel'), null);
|
||||||
|
|
||||||
|
// a refresh fills it: the mock rolls every slot
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('.toolbtn')].find((b) => /刷新/.test(b.textContent))?.click());
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('.shopbar__cards .scard--empty').length === 0, { timeout: 5000 });
|
||||||
|
assert.equal((await cards(page)).length, n + 1);
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the upgrade itself: tapping the level card twice opens the fifth slot empty, the cards shown stay', async () => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1600, height: 900 });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.waitForSelector('.shopbar__cards .scard', { timeout: 15000 });
|
||||||
|
// level 3 with its four operator slots, funds for the upgrade (the mock answers like the server: level 4 has five)
|
||||||
|
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => {
|
||||||
|
const shop = S.priv.shop;
|
||||||
|
const chess = shop.slots.filter((s) => s && s.kind === 'chess' && !s.sold).slice(0, 4);
|
||||||
|
shop.level = 3; shop.upgradePrice = 11; S.priv.funds = 50;
|
||||||
|
shop.slots = [...chess, shop.slots.find((s) => s && s.kind === 'item')];
|
||||||
|
}));
|
||||||
|
await sleep(300);
|
||||||
|
const before = await cards(page);
|
||||||
|
assert.equal(before.length, 4);
|
||||||
|
assert.ok(before.every((c) => !c.sold), 'four real cards');
|
||||||
|
await page.click('.lvcard'); // two taps: select, confirm
|
||||||
|
await sleep(200);
|
||||||
|
await page.click('.lvcard');
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('.shopbar__cards .scard--empty').length === 1, { timeout: 5000 });
|
||||||
|
const after = await cards(page);
|
||||||
|
assert.equal(after.length, 5);
|
||||||
|
assert.deepEqual(after.map((c) => c.empty), [false, false, false, false, true]);
|
||||||
|
assert.deepEqual(after.slice(0, 4).map((c) => c.text), before.map((c) => c.text), 'the cards shown stay as they were');
|
||||||
|
assert.ok(after.slice(0, 4).every((c) => !/已招募/.test(c.text)));
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Browser check of the in-match 观战席 capsule (GitHub #120, PR #120 by @salt-fishes; opt-in, ~10 s) on the in-match mock
|
||||||
|
// harness (public/dev/game-mock.html):
|
||||||
|
// SP_E2E=1 CHROME_PATH=… node --test test/ui/spectator-roster.e2e.test.js
|
||||||
|
//
|
||||||
|
// The room screen lists the spectator seats and lets the host remove one, but the route after 开始 is the game screen: a
|
||||||
|
// running match showed nothing of them. Now the top bar carries a capsule beside the latency — an eye and how many are
|
||||||
|
// watching, nothing at all while the room has no spectator; a tap opens the roster (names, an offline mark, the host's ✕ on
|
||||||
|
// every row: room.removeSpectator). Asserted: no capsule without spectators (and none in the default mock), the count and
|
||||||
|
// the rows, the ✕ only for the host, the request the ✕ sends, the roster and the count following the room.state that
|
||||||
|
// comes back, the capsule gone with the last seat, and the roster closing with Esc / 关闭. No console error. The server side
|
||||||
|
// (the seat freed at any time, room.closed {kicked}, no frame after it) is test/lobby.test.js and test/match/lobby-integration.test.js.
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
describe('the 观战席 capsule of the match top bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let browser;
|
||||||
|
let base;
|
||||||
|
before(async () => {
|
||||||
|
const { startServer } = await import('../../server/index.js');
|
||||||
|
const puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||||
|
base = `http://127.0.0.1:${srv.port}`;
|
||||||
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await srv?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function open(query) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1600, height: 900 });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.waitForSelector('.gtop', { timeout: 15000 });
|
||||||
|
await sleep(400);
|
||||||
|
return { page, problems };
|
||||||
|
}
|
||||||
|
const pill = (page) => page.evaluate(() => {
|
||||||
|
const el = document.querySelector('[data-testid="spectators"]');
|
||||||
|
return el ? { text: el.textContent.trim(), me: el.classList.contains('is-me') } : null;
|
||||||
|
});
|
||||||
|
const roster = (page) => page.evaluate(() => {
|
||||||
|
const ul = document.querySelector('[data-testid="spectator-roster"]');
|
||||||
|
if (!ul) return null;
|
||||||
|
return [...ul.querySelectorAll('li')].map((li) => ({
|
||||||
|
name: li.querySelector('.spec__name')?.textContent.trim(), offline: li.classList.contains('is-offline'), remove: !!li.querySelector('[data-testid="spectator-remove"]'),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const requests = (page) => page.evaluate(() => globalThis.__MOCK__.S().requests.filter((r) => r[0] === 'room.removeSpectator'));
|
||||||
|
|
||||||
|
test('no spectator, no capsule: the default match top bar is as it was', async () => {
|
||||||
|
const { page, problems } = await open('phase=COMBAT');
|
||||||
|
assert.equal(await pill(page), null);
|
||||||
|
assert.ok(await page.$('.gtop__net .ping'), 'the latency pill is still there');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the host: the capsule counts the seats, the roster lists them with a ✕ each, removing one updates both, the last one removes the capsule', async () => {
|
||||||
|
const { page, problems } = await open('phase=COMBAT&variant=spectators');
|
||||||
|
assert.deepEqual(await pill(page), { text: '2', me: false }, 'an eye and the count');
|
||||||
|
assert.ok(await page.$('.gtop__net [data-testid="spectators"]'), 'beside the latency pill');
|
||||||
|
assert.equal(await roster(page), null, 'closed until asked');
|
||||||
|
await page.click('[data-testid="spectators"]');
|
||||||
|
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||||
|
assert.deepEqual(await roster(page), [
|
||||||
|
{ name: '观战者甲', offline: false, remove: true }, { name: '观战者乙', offline: true, remove: true },
|
||||||
|
]);
|
||||||
|
// ✕ on the first row: room.removeSpectator { playerId } goes out, the room.state that follows takes the row away
|
||||||
|
await page.click('[data-testid="spectator-roster"] li:first-child [data-testid="spectator-remove"]');
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('[data-testid="spectator-roster"] li').length === 1, { timeout: 5000 });
|
||||||
|
assert.deepEqual(await requests(page), [['room.removeSpectator', { playerId: 'sp_1' }]]);
|
||||||
|
assert.deepEqual((await roster(page)).map((r) => r.name), ['观战者乙']);
|
||||||
|
assert.equal((await pill(page)).text, '1');
|
||||||
|
// Esc closes the roster, the capsule stays
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await roster(page), null);
|
||||||
|
assert.equal((await pill(page)).text, '1');
|
||||||
|
// the last seat: the open roster and the capsule go with it
|
||||||
|
await page.click('[data-testid="spectators"]');
|
||||||
|
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||||
|
await page.click('[data-testid="spectator-remove"]');
|
||||||
|
await page.waitForFunction(() => !document.querySelector('[data-testid="spectators"]') && !document.querySelector('.modal'), { timeout: 5000 });
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a player who is not the host sees the roster but no ✕; 关闭 closes it', async () => {
|
||||||
|
const { page, problems } = await open('phase=COMBAT&variant=spectators,notHost');
|
||||||
|
assert.equal((await pill(page)).text, '2');
|
||||||
|
await page.click('[data-testid="spectators"]');
|
||||||
|
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||||
|
assert.deepEqual((await roster(page)).map((r) => r.remove), [false, false], 'only the host removes');
|
||||||
|
assert.match(await page.evaluate(() => document.querySelector('.modal')?.textContent ?? ''), /创建者可以把观战者移出/);
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /关闭/.test(b.textContent))?.click());
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await roster(page), null);
|
||||||
|
assert.deepEqual(await requests(page), []);
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user