Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price

- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-09-30 19:12:36 +08:00
1 parent d4f868cd19
commit 15291c2293
104 files changed
+5539 -662

No files matched your search

+4 -3
View File
@@ -21,7 +21,8 @@
- 真实 Spine 小人、官方 BGM / 音效、表情(6 套 × 6 个);可选的官方 3D 棋盘(需从本机客户端提取贴图)。
- 作战特效:狙击曳光弹与枪口火光、法术 / 治疗 / 敌方光球、抛物线炮弹与爆炸、回环射手的回旋镖(飞出再飞回)、蕾缪安 3 技能的锁定与逐发炮击、近战斩击弧光、技能光柱与脚下光环;低画质只保留必要特效。
- 漏怪时顶栏的目标生命值实时减少(结算时确定);点选、拖放和配发装备都按地上的方格:点哪个方格就选中那个方格上的干员,拖动时模型始终在指针 / 手指下。
- 被击倒的干员留在原地并显示再部署倒计时圈,敌我单位显示官方样式的元素损伤图标(含爆发冷却);干员详情显示实时数值(作战中的当前数值 / 休整期的开战数值)。
- 被击倒的干员留在原地并显示再部署倒计时圈(联防开始时已倒地的干员同样倒地倒计时),敌我单位显示官方样式的元素损伤图标(含爆发冷却);干员详情显示实时数值(作战中的当前数值 / 休整期的开战数值)。
- 战斗规则对照官方数据与 PRTS 核对:敌人索敌顺序、按接触半径阻挡(阻挡者倒下时由接触的干员接替)、近地悬浮敌人算空中单位、巨型领袖约 5×3 格的受击范围、元素损伤与无来源的元素爆发。
- 只有一名玩家时(独立模拟,或同盟模拟只带 AI 队友)除作战外不计时;选策略每人 30 秒、只有一个倒计时;机变选卡和购买一样需要二次确认。
- **战斗在各玩家浏览器里模拟**(和官方一样),服务器只管经济与回合:一台低功耗 Windows 小主机即可开服。
@@ -90,7 +91,7 @@ npm start # http://localhost:3000
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」 |
手机和平板也能玩(触摸拖拽、长按查看),推荐横屏;电脑推荐 Chrome / Edge / Firefox / Safari 最新版。显卡较弱时可在「设置」里调低画质,或在网址后加 `?board=2d`(强制 2D 棋盘)/ `?render=fallback`(不用 WebGL 的简化 DOM 画面)。
手机和平板也能玩(触摸拖拽、长按查看),推荐横屏(细长屏幕上休整期镜头会自动微调,整备区不会被购买栏挡住);电脑推荐 Chrome / Edge / Firefox / Safari 最新版。显卡较弱时可在「设置」里调低画质,或在网址后加 `?board=2d`(强制 2D 棋盘)/ `?render=fallback`(不用 WebGL 的简化 DOM 画面)。
## 配置
@@ -113,7 +114,7 @@ npm start # http://localhost:3000
## 测试
```bash
node --test # 单元 + 集成测试(约 2500 项;缺少素材 / 浏览器的用例会自动跳过)
node --test # 单元 + 集成测试(约 2600 项;缺少素材 / 浏览器的用例会自动跳过)
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径)
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材
```
+1 -1
View File
@@ -1 +1 @@
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2"},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo"},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai"},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2"},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerLine truncated
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2"},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo"},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai"},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2"},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerLine truncated
+3 -2
View File
@@ -329,7 +329,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
|---|---|---|
| `key`, `name`, `level`, `rank`, `handbookIndex` | …, `"萨卡兹枯朽前锋"`, `0`, `"NORMAL"`, `"NAC18"` | `rank` NORMAL / ELITE / BOSS |
| `desc`, `descRaw`, `applyWay` | …, `"MELEE"` | `applyWay` MELEE / RANGED / ALL / NONE |
| `stats` | `{"maxHp":4700,"atk":300,"def":100,"res":40,"moveSpeed":0.8,"bat":3,"aspd":100,"rangeRadius":0,"rawRangeRadius":-1,"blockCnt":1,"massLevel":1,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys","arts"],"motion":"WALK","immunities":{…},"otherImmunities":[],"tauntLevel":0}` | level-0 (unscaled). **`rangeRadius` ≥ 0 is the normal-attack radius under the DESIGN §5.5 contract: `> 0` ⇔ the enemy attacks units it is not blocked by; always 0 for `applyWay` MELEE** (melee enemies only hit their blocker; 粉碎攻坚手/宿主士兵/深池方阵步兵 carry an official 1.2–2.5 used by abilities) and negative sentinels become 0. `rawRangeRadius` = the official value. `blockCnt` = block slots it consumes; `lpr` = lifePointReduce; `dmgType` = first handbook damage type (`phys`/`arts`/`heal`/`none`/…), full list in `dmgTypes`. Undefined database fields (`m_defined:false`) holding the zero value fall back to the defaults `lpr` 1, `bat` 1, `aspd` 100, `moveSpeed` 1 (see §15) |
| `stats` | `{"maxHp":4700,"atk":300,"def":100,"res":40,"moveSpeed":0.8,"bat":3,"aspd":100,"rangeRadius":0,"rawRangeRadius":-1,"blockCnt":1,"massLevel":1,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys","arts"],"motion":"WALK","immunities":{…},"otherImmunities":[],"tauntLevel":0}` | level-0 (unscaled). **`rangeRadius` ≥ 0 is the normal-attack radius under the DESIGN §5.5 contract: `> 0` ⇔ the enemy attacks units it is not blocked by; always 0 for `applyWay` MELEE** (melee enemies only hit their blocker; 粉碎攻坚手/宿主士兵/深池方阵步兵 carry an official 1.2–2.5 used by abilities) and negative sentinels become 0. `rawRangeRadius` = the official value. `blockCnt` = block slots it consumes; `lpr` = lifePointReduce; `dmgType` = first handbook damage type (`phys`/`arts`/`heal`/`none`/…), full list in `dmgTypes`. `elementRes` = 损伤抵抗 (official `epResistance` = EP_RESISTANCE, % off every element gauge fill; 10 on 转译基底·α, 0 on every other enemy here) and `elementDmgRes` = 元素抗性 (`epDamageResistance`, % off 元素伤害 — the bursts' HP damage; 0 on every enemy here — newer event enemies of the full database reach 10–15 on both) — PRTS 元素 / 游戏数据基础 and the enemy pages' 损伤抵抗 / 元素抗性 rows. Undefined database fields (`m_defined:false`) holding the zero value fall back to the defaults `lpr` 1, `bat` 1, `aspd` 100, `moveSpeed` 1 (see §15) |
| `abilities[]` | `{"text":"位于源石污染区内时,攻击造成法术伤害","textRaw":"…","format":"NORMAL"}` | handbook ability list |
| `talents` | `{"bb":{"1.hp_ratio":0.2,"1.damage_scale":0.5},"bbStr":{}}` | talentBlackboard |
| `skills[]` | `{"prefabKey":"2","priority":0,"cooldown":50,"initCooldown":0,"spCost":0,"bb":{"hp_ratio":0.02},"bbStr":{"branch_id":"boss_summon_enemy","enemy_key":"enemy_1005_yokai"}}` | `branch_id` → `waves[t].branches` |
@@ -341,6 +341,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
| `templateSlot` | `"N"` | placeholder slot when the key is a template key |
| `summons[]`, `inactiveIn[]`, `seasonOverride` | | spawned enemies; modes banning it; overridden fields |
| `iconId`, `spine` | `"enemy_1422_lrsldr"` | asset ids |
| `hitArea` | `{"w":4.95,"h":2.95,"dx":0,"dy":1}` (`enemy_9013_acstmk`) | **huge units only** (巨型单位, 7 keys: 假想敌:胄 ×2, 假想敌:管 ×2 — the 隐秘核心 one `dx` 1 —, 盐风主教昆图斯, 阿利斯泰尔,帝国余晖, “萨米的意志”): the hit rectangle, `w` tiles along the columns × `h` along the rows, centred on the unit's position moved `dx` columns right / `dy` rows up (sim/body.js; user playtest #5). Not in the game tables (the collider lives in the prefab): `tools/build-data.mjs HIT_AREAS` by `prefabKey`, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" and PRTS盟约记录 (the season's 阿利斯泰尔 / “萨米的意志” versions); absent = a point. All 7 are also 自缚 + 无法被阻挡 (PRTS 天赋 — not a data field either: content/bosses.js `SELF_BOUND`) |
## 10. `factions.json` — special enemies (特训敌人)
@@ -400,7 +401,7 @@ exactly (the builder warns if a multi-fragment template ever appears).
| `mapChars[]` | `{"key":"char_605_cmedic","alias":"char_605_cmedic#1","pos":[10,2],…}` | band_amedic map characters (→ `tokens.json`) |
| `special` | `{"mire":{"intervalSec":3,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters |
| `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW buildable ALL/MELEE (ranged may use them too), `rangedOnly` = high ground / active platforms; tiles under active crates/mounds excluded |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW buildable ALL/MELEE (ranged may use them too), `rangedOnly` = high ground / active platforms; tiles under active crates/mounds excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — plus the remake's road-over-floor preference: among equal-length routes the fewest non-blockable floor / gate tiles, and smoothing never cuts across floor the grid route does not walk, e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane; DESIGN §14 corrections) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
+151 -12
View File
@@ -65,6 +65,7 @@ server/
buffs.js Buff model, status catalogue (stun, freeze, cold, sleep, slow, fragile, stealth, …)
damage.js damage & heal pipeline, element gauges
targeting.js target selection helpers, range tests, priorities
body.js hit areas of huge enemies: the one helper behind every range test on enemies (§3, §19.4)
projectiles.js projectile flight/impact
skills.js skill runtime: SP, triggers, durations, ammo, charges; interprets SkillSpec
ai.js enemy movement/attack AI, operator attack loop
@@ -127,7 +128,7 @@ Ownership rule for parallel agents: **only edit files assigned to you**; if you
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). Melee chess may stand only on LOW tiles with buildable ALL/MELEE; ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`.
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating. The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles. Radius ranges (enemies, splash, auras) use Euclidean distance in tiles.
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径").
- **Fields at runtime:** a `Battle` receives a **field rect** (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
- **Speed:** `tilesPerSecond = moveSpeed × MOVE_SCALE` with `MOVE_SCALE = 0.5` (in `sim/constants.js`, tunable).
@@ -180,7 +181,8 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4)
colOffset, // 0 for own field; +8 for the right half in unite/boss
units: [ { uid, kind: 'chess'|'token', chessId /* golden id if elite */, tokenId?, row, col,
items: [itemId...], // equipped items (golden ids when merged)
carryState?: { hpPct, sp, skillActive } } ], // unite: helper units keep end-of-combat state
carryState?: { hpPct, sp, skillActive } | { down: true } } ], // unite: helper units keep end-of-combat state;
// { down: true } = knocked out at its end: deployed, then forced out (§19.3)
bonds: { [bondId]: { count, active, tier, layers } }, // snapshot from bondsMeta at combat start
bandId, playerEffects: [ EffectRef ], // active band/choice/team effects with battle parts
lpForBoss?: number }
@@ -255,13 +257,13 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free, order top→bottom then left→right (right-side boss player: right→left). Deploy effects fire (`deploy` with `initial: true`). Then `battleStart`.
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays **down** on its tile from the knock-out until it redeploys (b.snap `down`, §18.3: counting / waiting for DP / its tile taken); summons, devices and enemies leave at once.
- **Blocking**: a ground op with free capacity blocks a ground, non-unblockable enemy whose position enters its tile (|dx|,|dy| ≤ 0.5). Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). Blocker death frees its enemies.
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) units it blocks (melee), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) cannot hit FLY enemies. Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact.
- **Initial deployment** at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; `aggroSeq` ranks every start-of-battle summon after all operators); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays **down** on its tile from the knock-out until it redeploys (b.snap `down`, §18.3: counting / waiting for DP / its tile taken) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer [ASSUMED] with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). A **melee** blocker (data `position` MELEE — 重装要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; `Battle.blockedTargets`, `targeting.js meleeUnit`); a ranged operator on a melee tile blocks but attacks only what its range holds ("远程位干员通常而言不会优先攻击自己阻挡的目标", §19.2). The huge leaders are 自缚 + unblockable (§7).
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) the enemies it blocks (melee units only, in range or not), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact.
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%; do nothing otherwise (SP still regenerates).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops within radius: priority blocker → highest taunt → **latest deployed**; they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore blocking and ground obstacles, follow their checkpoint route.
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul`; shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × (1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素). A full gauge bursts with the side-specific official effects (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops within radius: the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1); they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route.
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"); shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
- **End**: all spawns done and no enemies alive ⇒ `cleared`; `time ≥ timeLimit` ⇒ `timeout` (remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0).
@@ -314,7 +316,9 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
UNITE (co-op only, if ≥1 leaked & ≥1 perfect: ≤2 perfect helpers in `unite.js helperOrder` order (research 08 §5 / PRTS 帮助:
most units on the field > an active bond > most standing units > seat; the pair ordered by units > active bond >
Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains)
Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains;
a helper's operator knocked out at the end of its own combat enters down — carryState { down: true },
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer [ASSUMED] (§19.3))
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast)
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
real s after 150 real s (§4, m.public.overtimeAt); no layer gains)
@@ -378,7 +382,7 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted; devices/terrain.
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs).
---
@@ -620,7 +624,7 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si
- **Battle** (`app.js battleUnitAt` → `pickBattle`): an ally on the tile under the pointer — a knocked-out operator lying on its tile (§18.3) included. An enemy within `ENEMY_REACH` 0.6 tile [ASSUMED], using whichever is nearer: its ground position from the pointer's point on the ground, or its drawn body on screen (the upright line from its feet up to its head, in its px per tile) — so a press on a tall enemy's or the boss's torso or head picks it; a flying enemy uses its drawn body only. When an ally tile and an enemy both qualify the nearer (in tiles) wins; ties → the ally, then the front-most. Dead and dying units are not picked (a knocked-out operator waiting on its tile is).
- **Drag**: the dragged unit is held under the pointer, mouse and touch alike — its drawn (lifted) feet `DRAG_HOLD_TILES` = 0.45 tile (in its own px per tile) below the pointer [ASSUMED: the v2.1 offset], so the pointer rests on the chibi; an item plate is centred on the pointer. The drop target, its highlight, `tileHover` and the direction wheel's tile are the tile under the pointer. An item dropped on a tile equips the unit on that tile (`ui/gameLogic.js canPlace` / `dropIntent`; an empty tile says 请将装备拖拽至干员身上). A release over DOM covering the canvas (the shop bar) is `'outside'`: the piece goes back.
- **Touch**: the field canvas cancels `touchend` (`app.js onTouchEnd`, non-passive). A tap's compatibility mouse events and click came after `touchend`, hit-tested at the finger again — on the underframe the tap had just opened over the unit's tile (clamped under the top bar for rows 11–12 at 844×390), which pressed 撤退 / 出售.
- **Notched phones**: the shop bar keeps its place at the bottom edge instead of rising by the bottom safe-area inset (`css/devices.css .gm__hud > .shopbar`): raised by it the bar covered ~3/4 of bench pads 2–9 at 844×390 with the iPhone insets (23 % of those tiles left free, 71 % now — as without insets), and bench pieces are pressed and dropped on by their tile.
- **Notched phones**: the shop bar keeps its place at the bottom edge instead of rising by the bottom safe-area inset (`css/devices.css .gm__hud > .shopbar`): raised by it the bar covered ~3/4 of bench pads 2–9 at 844×390 with the iPhone insets, and bench pieces are pressed and dropped on by their tile. (The v2.3 figures — 23 % of those tiles free before, 71 % after — are superseded by §19.8: the prep camera now clears the HUD, every bench pad 100 % free on phones.)
- Removed: the pixel probe (`drawnAt`, `hoverProbeAt`), body shapes (`pickShape`, `PROBE_MARGIN`, `BODY_H`), `TOUCH_LIFT_TILES`, the drag `dropPoint` hook, `pieceDragOver`, `ui/gameLogic.js pickPieceAt` / `equipRetarget`. `UnitView.bounds()` is a plain body rectangle again (tooltips and `pieceScreenRect` only).
- Tests: `test/render/pick.test.js`, `drag.test.js`, `unitview.test.js`, `test/ui/gameLogic.test.js`, `test/ui/playtest4.test.js` (shop bar); browser: `test/render/models.browser.test.js` (every tile of every board, high ground, bench and temp; battle allies and enemies incl. torso and head; the model under the pointer at every drag step; the wheel on the pointer's tile; equipment by tile; phone touch incl. the ghost click and the shop-bar release), `test/render/downelem.browser.test.js` (a knocked-out operator picked on its tile), `test/ui/playtest2.e2e.test.js` (the top-row tile under the bond strip; the notched phone's bench).
@@ -633,7 +637,7 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si
### 18.3 Knocked-out operators and element gauges (#9, #8) — `sim/Battle.js`, `sim/damage.js`, `render/interp.js`, `render/units.js`
- **Knocked out, not gone**: `Battle.snapshot()` lists `down: [[id, respawnAt, respawnTime, state]]` (§8.2) for operators knocked out (reason `'killed'`, not withdrawn), deployed at least once and with a finite respawn timer; `state` = sim/constants.js `DOWN_STATE` (0 counting, 1 timer done but DP < cost, 2 timer done but the home tile is taken — the official auto-redeploy rule, research 01 §4.3). `Battle.isDown(u)`; `fieldMeta()` lists them too, so a client joining mid-battle (reconnect, observer, replica) shows them. The renderer (`interp.js downAt`, `app.js syncBattle`, `units.js setDown`) keeps such an operator's view on its tile: its Spine Die clip plays once and holds its last frame (a collapsed / kneeling pose), lightly greyed (tint 0xb4b4b4, alpha 0.92), under a **redeploy ring** above the head — a dark disc with a mint arc filling as the timer runs and the seconds left (game s); timer done: a full pulsing amber ring "DP" (waiting for DP) or red "!" (its tile taken). The `deploy` event plays the Start clip and restores the normal look; a view created for an operator already down starts on the held pose; leaving the list without a redeploy fades it out. Summons, devices and enemies vanish as before. [ASSUMED] look: the official knocked-down / ring art is in no extracted autochess bundle.
- **Knocked out, not gone**: `Battle.snapshot()` lists `down: [[id, respawnAt, respawnTime, state]]` (§8.2) for operators knocked out (reason `'killed'`, not withdrawn — or `FORCED_EXIT`, entering 联防 knocked out, §19.3), deployed at least once and with a finite respawn timer; `state` = sim/constants.js `DOWN_STATE` (0 counting, 1 timer done but DP < cost, 2 timer done but the home tile is taken — the official auto-redeploy rule, research 01 §4.3). `Battle.isDown(u)`; `fieldMeta()` lists them too, so a client joining mid-battle (reconnect, observer, replica) shows them. The renderer (`interp.js downAt`, `app.js syncBattle`, `units.js setDown`) keeps such an operator's view on its tile: its Spine Die clip plays once and holds its last frame (a collapsed / kneeling pose), lightly greyed (tint 0xb4b4b4, alpha 0.92), under a **redeploy ring** above the head — a dark disc with a mint arc filling as the timer runs and the seconds left (game s); timer done: a full pulsing amber ring "DP" (waiting for DP) or red "!" (its tile taken). The `deploy` event plays the Start clip and restores the normal look; a view created for an operator already down starts on the held pose; leaving the list without a redeploy fades it out. Summons, devices and enemies vanish as before. [ASSUMED] look: the official knocked-down / ring art is in no extracted autochess bundle.
- **Element gauges, official rules** (PRTS 元素 / 术语释义; `damage.js`): gain × `elemTakenMul` × (1 − 损伤抵抗 / 100), 损伤抵抗 = data `epResistance` (EP_RESISTANCE; it used to read `epDamageResistance` as a fraction). A burst's **爆发冷却** (= its duration: 灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s; the operators' 侵蚀 had none) locks EVERY element of the unit — nothing fills or recovers (`reduceElement`) — and its end resets EVERY gauge (`burstLocked(unit)` takes no element). 神经 applies the stun (operators) / 3 麻痹 (enemies) before its damage, 侵蚀 its DEF cut before its damage; the operators' 凋亡 burst holds 阻回 (`noSp`: `skills.js gainSp` refuses SP of every kind — time, attack, hurt, granted) + 静默, −1 SP/s and 100 arts/s.
- **The gauge on screen** (official display: one icon, the "当前损伤元素"): `damage.js elementView(u, now)` → b.snap `elem` (§8.2): the fullest gauge, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经 < 侵蚀 < 灼燃 < 凋亡); during a 爆发冷却 the bursting element, refilling over the cooldown. `interp.js` appends it to the unit's tuple; `units.js` draws it right of the HP / SP bars — operators: the element's disc with its glyph; enemies: a smaller plain disc of the element colour (official rule) — with a white ring for the remaining 元素值 (1 − fill). One procedural 512×576 atlas (`textures.js hudRings` / `ringArc`, 49 arc frames and the discs), sprites made on first use and hidden when idle. The burst's lock status (`burnBurst`, `neuralBurst` …) is not repeated in the status row while the element icon shows it (only a feed without `elem`, e.g. an older recording, shows it there). [ASSUMED] placement (the official draws it under the model, the remake's bars are above the head) and size (0.3 tile, enemies ×0.8).
- Recordings (`tools/record-battle.mjs`) carry both keys, so the render demo shows them. Tests: `test/sim/downelem.test.js`, `test/sim/combat.test.js`, `test/render/downring.test.js`, `test/render/interp.test.js`, `test/render/downelem.browser.test.js`.
@@ -662,3 +666,138 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si
### 18.7 The occasional giant UI flash (#13) — `render/fx.js pop`
The first in-battle layer gain of each bond (b.ev `'layer'`) pops that bond's icon over the field (`FxSystem.pop`). The icon is `PIXI.Texture.from(url)` — a 1×1 placeholder until its image has loaded — and the pop sized the sprite from it (46 / 1): the bond icon was drawn ~5000 px across the whole screen for the pop's 1.4 s, once per bond per page (73 official garrisons gain layers in battle, e.g. 送葬人's). Now it is sized (46 px along its longer side) only once its texture is valid, hidden until then. Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi objects > 25 % or showing a whole atlas page, CSS scale ≥ 2); every other large object seen is a designed full-screen layer. Tests: `test/render/fxpop.test.js`, `test/render/flash.browser.test.js`.
---
## 19. User playtest #5 (v2.4): hovering enemies, target order, blocking, 联防, elements, huge bosses, maps, phone HUD
Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in landscape). Where each is handled: #1 operators that only attack ground units hit 近地悬浮 enemies → §19.1; #2 an operator lying knocked out (濒死) vanished when 联防 began instead of counting down and redeploying → §19.3; #3 element damage against the 海嗣 felt weak, audit every operator's and enemy's element amounts and gauges → §19.5 (enemies, the gauge pipeline) and §19.6 (the player side); #4 an enemy held between two operators walked off when the rear one died instead of the front one taking it over → §19.2; #5 the enemies' target order differed from the official → §19.1; #6 act2 m01's normal field had its two wind lanes but no blower machines, which showed in the boss round instead → §19.7; #7 the tiles framed by the 1×3 pipes on the boss map are deployable officially, check every map → §19.7; #8 the bottom-left settings icon was deformed → §19.8; #9 on a long phone screen the shop bar covered the bench → §19.8; #10 the huge bosses could only be hit around one tile → §19.4. A follow-up message added #11 至简 should cost 1 (his 特质 says 购买价格为1) → §19.9. Normative lines rewritten with it: §3 range test, §5.1 `carryState`, §5.5 (initial deployment, DP / down, blocking, operator attacks, enemy AI, damage), §6.1 UNITE, §7 bosses; §18.3's `isDown` now includes `FORCED_EXIT`, and §18.1's notched-phone figures are superseded by §19.8. The integration also applied the workstreams' cross-checks (hasHp in the pipeline, 脆弱 vs 元素伤害, element healing, 淤困, 塑心 S2, splash as a 中点判定); they are listed in the sections they belong to. Three QA passes over the integrated branch then left residuals, fixed in their sections: 掠海漂移体's model crawls after its drop (§19.1); blocked-first and out-of-range selection are melee-only (§19.2); a forced-out operator's timer takes the battle-start redeploy effects (§19.3); the gauge intake's 5 % floor (§19.5); the boss field's machines no longer stand under the bench in the normal views, and the fallback board draws the boss half in the boss prep (§19.7).
### 19.1 Hovering enemies and the enemies' target order (#1, #5) — `sim/units.js`, `targeting.js`, `ai.js`, `Battle.js`, `content/enemies.js`, `bosses.js`, kits, `match/bot.js`, `render/units.js FORMS`
- **#1 cause**: `float()` only cancelled hits from non-`'ranged'` profiles and otherwise left the floater a ground unit, so 迷迭香 (攻击对小范围的地面敌人) shot and splashed it (5.2k damage in a sim run) and terrain, traps and 浮空 treated it as ground. **Official** (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义 / 行动方式): the unit is unblocked and counts as a real air unit (算作空中单位) that keeps ground pathing.
- **Now** `Unit.isFlying` is the one air-unit test: `motion` FLY, or an enemy flag `float` (近地悬浮) or `levitate` (浮空); pathing, displacement tiles and 诱导 read `motion`. canHitFly / groundOnly, splash, chain, the kits' "地面敌人" filters, ground terrain and traps skip air units; 失衡免疫 is the flag `noDisplace`; 浮空 is refused only on data flyers and already-levitated units (PRTS 行动方式), so 阿罗玛 can lift a floater. Blocking follows (§19.2): only blockFly units block air units.
- **Enemy kits**: 掠海漂移体 — 晕眩 / 冻结 / 沉睡 → 爬行模式 for good (0.5 s stun), then it only attacks its blocker; 不会攻击飞行单位. Its model follows (integration QA: melee operators hitting an enemy that still looked airborne): the sim's fx `phase` `crawl` switches the view to the skeleton's crawl clips (`*_02`, after its `Change` clip) — `render/units.js FORMS`, `spine.js setForm`, kept on the unit info for a view built later (a reload mid-battle replays no events: such a view hovers until then). 吉兆飞鳞 — 晕眩 / 沉睡 → 8 s 晕眩模式 (it stays down while still asleep), a freeze grounds it when the freeze ends, its sprint is the final ×3. Of the 32 official floaters only these two appear in this mode (the bot's `HOVER_KEYS`; `fieldModel` counts them as air routes on ground paths).
- **Skill effects** (per PRTS 备注; the official per-ability targetMotion is not in the data): ground-only — 隐德来希 S2 血镰, 归溟幽灵鲨 拥抱自我, 琳琅诗怀雅 S3, 乌尔比安 S1 [ASSUMED: a 捕网 like 雪雉's]; PRTS 可对空 — 德克萨斯 S2, 忍冬 S2, 焰尾 S2, 缄默德克萨斯 S3, 凛御银灰 S2, 玛恩纳 S3; no 对空 note, [ASSUMED] to hit air — 风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts (“耀阳”, 沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体.
- **#5 official**: PRTS 索敌 (敌方) "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被攻击)→最早出现"; PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", the right-hand boss player mirrored.
- **#5 changes**: `Battle.start` deploys operators row-major, then summons; `aggroSeq` ranks every start-of-battle summon after all operators (`deploySeq` stays the deployment identity). An enemy's own rule `e.profile.canTarget` filters its candidates before the sort (萨卡兹枯朽战车 no longer idles when its preferred target stands on high ground; it, 掠海漂移体 and “萨科塔之眼” ignore flying allies). Special priorities break ties by taunt, then latest deployed (`targeting.js aggroCmp`): 铳 / 昆图斯 highest DEF, 胄 highest / lowest ATK, the 碎铳之簧 bounce, 唱沙 / 沙狱 max HP, “自在” nearest. Kept (PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿"; 索敌的概念): a stealthed ally (隐匿 / 迷彩 / 排气格栅) is attacked only by the enemy it blocks. [ASSUMED] "从上到下>从左到右" = row-major; on a shared field (联防, boss) the players' i-th operators deploy together, then the summons (it used to be player after player, which made the leader prefer the later player's pieces).
- Tests: `test/sim/playtest5-targeting.test.js` (18; 13 fail on 731a01c), `test/content/enemies_bosses.test.js` (吉兆飞鳞), `test/match/bot.test.js` (a hovering wave counts as air), `test/render/forms.test.js` (the crawl clips); browser (opt-in) `test/render/forms.browser.test.js` (the real sim feeding the field view).
### 19.2 Blocking by contact radius, and the hand-over (#4) — `sim/constants.js BLOCK_RADIUS`, `Battle._checkBlock` / `blockedTargets`, `ai.js`
- **Cause**: an operator blocked only an enemy standing on its own tile (§5.5's old "|dx|,|dy| ≤ 0.5"). An enemy that passed a full front operator was blocked by the rear one, on the rear tile; when the rear operator died, the front one — which the enemy still overlapped — never took it.
- **Official** (PRTS 游戏数据基础 §阻挡半径; 作战机制 "中点判定…案例:阻挡"): contact = the enemy's position within the blocker's radius of its centre, compared as squared distances — ground 0.70709997 (² 0.49999037), air (起飞 / blockFly vs flyers) 0.8944, devices (阻隔工事 / 障碍物) 0.4472.
- **Now** (§5.5 Blocking): every tick, moving or not, an unblocked blockable enemy is blocked by the nearest operator in contact that has free capacity for its weight — the front operator takes over when the blocker dies, is withdrawn or is stunned (`stun` releases its enemies); an operator whose capacity frees up grabs an overlapping enemy; an enemy that finds no room walks on. A head-on enemy stops at contact, ≈ 0.71 tile from the blocker's centre, outside its tile (the small official deceleration is not modelled). A knocked-out or undeployed operator never blocks.
- **Consequences, both official**: "可以选择且优先选择阻挡单位" (PRTS 选择器: the selector of units whose range is smaller than their block radius) — a **melee** blocker (data `position` MELEE: 要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first (`Battle.blockedTargets` → `ai.js acquireTargets`, `targeting.js sortEnemyTargets`, the DEFAULT skill trigger — PRTS 卫戍协议/帮助 "敌人被近战干员自身阻挡"); 瑕光 S2 also sleeps the enemy she blocks (PRTS 备注). A rear operator whose range ends at the blocker's tile no longer reaches the enemy held in front of it (a regression sweep of 143 blocker × skill pairs: own-tile defenders 0.96–1.05×).
- **Ranged operators on melee tiles** (卫戍协议 "所有行动内远程干员可部署在近战位") block, but target by their range alone (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人"): no blocked-first priority, no out-of-range target, no skill cast for the enemy they hold (`targeting.js meleeUnit`). Found by the integration QA: 能天使 / 夕 facing away kept shooting the enemy held behind them (9264 / 5860 damage in 20 s).
- 灰毫 S2 专注轰击 casting (block 0, ranged only) as soon as she blocks is the basic strategy: the skill has no skill range and "敌人被近战干员自身阻挡" satisfies the attack-target condition; the 下半 重装 special strategy "受到伤害时释放技能" covers skill 1 only (BWIKI "重装职业干员 技能1,受到伤害后自动释放"; the data row has `skillIndex 0`, with `-1` as the all-skills sentinel — research 03 C1; PRTS's strategy table does not name the skill), so her S1 is TAKE_DAMAGE and S2 stays DEFAULT.
- [ASSUMED] the nearest blocker wins, ties by scan order.
- Tests: `test/sim/playtest5_blocking.test.js` (16, incl. the user's case on act2 m01 with real operators, the ranged blocker and 银灰 as a melee-position ranged attacker), adjusted geometry in `kits_alt_t5` / `engine-requests` / `enemies_bosses`.
| Contact | Official (PRTS) | v2.3 | Now |
|---|---|---|---|
| ground operator | d² < 0.49999037 (r 0.7071) | the enemy's tile = the operator's tile | d² < 0.49999037 |
| air (blockFly / 起飞) vs flyers | r 0.8944 | same tile | d² < 0.79995137 |
| device (阻隔工事 / 障碍物) | r 0.4472 | same tile | d² < 0.2 |
| blocker gone / capacity freed | contact re-checked continuously | only on the enemy's own tile | every tick, nearest free blocker |
### 19.3 Knocked-out operators in 联防 (#2) — `server/match/unite.js`, `sim/Battle.js`, `sim/constants.js FORCED_EXIT`, `render/app.js`, `render/units.js`
- **Cause**: `unite.js uniteBattleOpts` dropped every operator that `unitsEnd` reported `alive: false` — and its summons — from the 联防 input (an old [ASSUMED] rule). Every client and the server build the unite field from that input, so the operator was missing for the authority, the partners, observers and the `SP_COMBAT=server` fallback alike.
- **Official** (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)".
- **Now**: the operator is fielded with `carryState: { down: true }` (§5.1); its summons are fielded as the board has them. `Battle.start` deploys everyone (the initial `deploy` hook fires), then before `battleStart` sets its HP to 0 (the end-of-phase ratio, as `kill()`) and withdraws it with removal reason `FORCED_EXIT` = `'forcedExit'`. `Battle.isDown` accepts that reason, so it is in b.snap `down` and `fieldMeta` with its ring, shows 0 HP on the live card, and redeploys by §5.5 (timer done, own tile free, DP ≥ cost) at full HP. The forced exit is no new knock-out: no `kill` hook, no `death` with reason `'killed'`, no `deaths` count — 深海 / 不屈 revives, 崇高牺牲 layers and selfdead garrisons fired in the own combat. The client's `'die'` with that reason goes straight to the held knocked-down pose (`UnitView.die(true)`: no fall, no burst, no sound).
- [ASSUMED] the timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). If the user wants the own combat's remainder continued ("继续转再起倒计时"), the fallback is small: carry `respawnLeft` in `unitsEnd` / `carryState`. Also [ASSUMED]: the forced exit happens before `battleStart`; the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. Not covered: 召唤物仅修改技力 (summons enter 联防 fresh; `unitsEnd` lists operators only — carrying their SP would extend the client-reported result, `spec.js` digests and `fields.js` validation).
- Tests: `test/match/unitedown.test.js` (both combat modes, both helper halves, the real sim), `test/sim/downelem.test.js` (incl. 征召), `test/render/downring.test.js`, `test/match/combat.test.js`; browser (opt-in) `test/render/unitedown.browser.test.js`.
### 19.4 Huge bosses: hit area, 自缚 (#10) — `tools/build-data.mjs HIT_AREAS`, `sim/body.js`, `content/bosses.js SELF_BOUND`, `render/pick.js`
- **Cause**: every range test took the enemy's position tile, and the data had no body size, so a boss at (3,10) was hit only by ranges covering (3,10).
- **Official** (PRTS 天赋): "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" (管·隐秘核心 also 向右偏移1.0; PRTS盟约记录 gives the season's 阿利斯泰尔 and “萨米的意志” the same area); PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定". The same talent lines list 自缚 (PRTS 异常效果: 无法移动, not recognised as 束缚) and 无法被阻挡. 铳, 卢西恩 and the boss parts are regular units.
- **Now**: enemies.json `hitArea {w, h, dx, dy}` (by prefabKey); `sim/body.js` is the one helper behind every range test on enemies (the engine tile index, `enemiesInKeys`, `unitsInGrid`, `enemiesInRadius`, nearest / farthest priority, kits, tokens, bonds, professions, `support.onKeys`). A huge enemy occupies every tile its rectangle overlaps — 5 × 3, rows 3–5 × cols 8–12 around (3,10): the whole fence-framed pipe block, so an operator on the 1×3 fence tiles (§19.7) hits it. Radius rules measure to the rectangle (0 inside) [ASSUMED: PRTS calls collider tests the most common]; **splash around a struck target is a 中点判定** (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"): `enemiesInRadius(x, y, r, true)` counts every enemy by its position — the engine's profession splash and aftershocks and the kits' target-centred splash (焰影苇草's 灼痕 burst, 新约能天使 火力电台, 仇白 S1) — so a splash on an escort 0.53 tiles from the rectangle but 1.41 from the boss's centre no longer reaches the boss. Its position stays its 判定中心 (projectiles, its own attacks, splash centred on it, distance-scaled numbers). Integration pass: the reaperrange front line, 凛御银灰 S3's lateral line and the 香槟炸弹 tile trigger are body-aware too; movement, pathing, terrain, tile-entry tracking, placement heuristics and range keys taken from blocked enemies stay on the position (a huge enemy never moves and is never blocked).
- **自缚**: the co-op 昆图斯 walked its official route (3,10) → (2,2) off the block and leaked at ≈ 280 game s. `SELF_BOUND` (exactly the 7 hitArea keys) gives them a persistent `noMove` + `unblockable` buff at spawn (the mirrored co-op copy too).
- **Picking** (`pick.js AREA_PICK`): a press on the hit area or inside the drawn body box selects the boss, counted as 1 tile away, so an ally on the pressed tile or a nearer regular enemy wins.
- [ASSUMED] 胄·隐秘核心 keeps the upward offset although its PRTS section omits it: enemy_database gives `enemy_9013_acstmk_2` the prefabKey `enemy_9013_acstmk` — one prefab, one collider — while the 管 copy that PRTS lists with a different offset has a prefab of its own (`enemy_9021_acduml_2`); ask at the next playtest if the hidden round feels off. The rectangle is fixed in map space (not mirrored).
- Tests: `test/sim/playtest5_bossbody.test.js` (12, incl. the centre-point splash), `test/render/pick.test.js`, the Final Assault leak regression in `test/sim/battle.test.js`; browser (opt-in) `test/render/bossbody.browser.test.js`.
| Boss | Hit area (PRTS) | Tiles at its spawn | Talent |
|---|---|---|---|
| 假想敌:胄 (+ 隐秘核心 copy) | 4.95 × 2.95, up 1.0 (copy: [ASSUMED] same) | (3,10) → rows 3–5 × cols 8–12 | 自缚、不可阻挡 |
| 假想敌:管 / 管·隐秘核心 | 4.95 × 2.95, up 1.0 / + right 1.0 | (3,10) / (3,9) → rows 3–5 × cols 8–12 | 自缚,无法被阻挡 |
| 盐风主教昆图斯 | 4.95 × 2.95, up 1.0 | (3,10) → rows 3–5 × cols 8–12 | 自缚,无法被阻挡 (walked and leaked before) |
| 阿利斯泰尔 / “萨米的意志” (卫戍 versions) | 4.95 × 2.95, up 1.0 | (3,10) → rows 3–5 × cols 8–12 | 自缚,无法被阻挡 |
| 假想敌:铳, 卢西恩, the parts | none (point) | its tile | 铳 不可阻挡 only; 卢西恩 blockable |
### 19.5 Element gauges and enemy element damage (#3, enemy side and pipeline) — `sim/damage.js`, `content/enemies.js`, `content/bosses.js`
- **Verdict: official behaviour, not a remake bug.** Every 海嗣 has 损伤抵抗 (`epResistance`) 0 and 元素抗性 (`epDamageResistance`) 0 (in data/enemies.json only 转译基底·α has 损伤抵抗 10); gauges are 1000 (leaders 2000) and never decay; no 海嗣 talent changes element intake; every enemy element attacker deals ATK × its official ratio per hit × the round's ATK multiplier (47 checked). Boss levels override some values, which the runtime applies: 卢西恩 ATK 700 (solo 600) and 不祥幻影 200, × 0.12 per normal hit (skill 0.2) in boss_5 (level_act2autochess_h07_05(_s)); 盐风主教昆图斯 480 (solo 380) in h07_04. A blocked 底海滑动者 at 险境 R5 bursts 神经 on its 25th hit, ≈ 50 game s. "Weak" also has a player-side part — §19.6.
- **Official rules** (PRTS 元素 / 游戏数据基础 / 伤害分类; gamedata_const termDescriptionDict): gauge gain = 损伤值 × max(5 %, 1 − 损伤抵抗 %) (PRTS 游戏数据基础: DMG_e "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可"), then the 元素损伤倍率 effects; 元素伤害 = max(A(1 − 元素抗性 %), 5 %A); 元素脆弱 ("受到的元素伤害提升") raises 元素伤害 only; 脆弱 ("受到的物理、法术、真实伤害提升") never touches 元素伤害; bursts are 无来源 ("元素爆发通常造成无来源的伤害"; 无来源 = "无法被追溯伤害来源", yet the kill is traced).
- **Fixed (W3b)**: 元素脆弱 is the mod `elementalTakenMul` (元素伤害 only; it used to share the gauge rate `elemTakenMul`, so 炎佑's 20 % sped up fills instead of raising bursts); 元素抗性 reduces `'elemental'` with the 5 % floor (`expectedFinal` mirrors it); bursts carry DamageInfo `sourceless` — no damage-dealt multiplier or penetration of the filler, and the `hit` / `damaged` / `fatal` hooks see `source: null` plus `credit` (stats, kill and boss-pool share keep the filler; a real 深巡 bursting a 海嗣 dealt 10500 instead of 7000, 精准狙击镜 made a 侵蚀 burst 6500 instead of 5000); target-side reactions with no source condition read `source || credit` (sharing: 圣杯, 盲信之誓, 余音; 抵挡: 星熊 战术装甲, 拉普兰德 日晷). The enemies' 凋亡 虚弱 is 50 % × remaining ÷ 15 s each tick (it ran 1 s ahead); 麻痹免疫 is honoured (假想敌:铳, the 胄 parts); 淤困's host burst spreads 1000 (was 780); 灼藤's first attack covers the 3×3; 节日爵士乐手 channels on one locked target (20 % arts + 10 % burn every 0.5 s, 21 ticks, ends on 沉默, no re-cast mid-channel). Side effect: 隐德来希's 心烛 cancels bursts on a candle ("只受隐德来希攻击的影响").
- **Integration pass**: `damage.js hasHp` (alive, HP > 0; a boss: its pool) is the one predicate — `applyElement` refuses a target with no HP left, so no rider in a killing blow's `damaged` hook can burst a corpse (the content copies in tier5 / tier6 / items / tokens now import it); `'elemental'` damage skips `dmgTakenMul` (脆弱 and the other "受到的伤害±" effects [ASSUMED: they share that multiplier]) — only 元素脆弱 scales it; element healing (`reduceElement`) lowers **every** element type by the amount on its own (PRTS 菲莱 备注: 清除元素损伤 = "一次等同于自身最大元素值的全类型元素损伤治疗"; it healed the amount across all types in total); 淤困's "受到的元素损伤提高至130%" is an `elementHit` multiplier at priority 20 like every operator-side "受到的元素损伤±", before a 损伤屏障 (it was the `elemTakenMul` mod, applied after the barrier).
- **Verified unchanged**: the burst effects on both sides; cooldowns 10 / 10 / 15 s (enemies' 侵蚀 8 s), locking then resetting every gauge; leaders take no 侵蚀 in the Final Assault (PRTS 盟约记录); 炎佑's aura = 20 % 元素脆弱 within 1.5. **Differs, not changed**: the gauge's look (official: icon + white bar under the model; §18.3 [ASSUMED]). [ASSUMED] 爵士乐手's first tick one interval after the cast. The gauge intake's 5 % floor (`elementIntake`, integration QA) is unreachable with the data (损伤抵抗 ≤ 10).
- Tests: `test/sim/playtest5_elements.test.js` (18), `test/content/playtest5_followups.test.js` (pipeline guard, 脆弱, element healing), `combat.test.js`, `test/content/{kits_t5,tokens_devices,playtest4_summons,enemies_bosses}`.
| 海嗣 (user's case) | Official ratio | Per hit / s at base ATK | Hits to a 1000 gauge | Time |
|---|---|---|---|---|
| 底海滑动者 (ATK 280, BAT 2.0) | 15 % ATK 神经 | 42 | 24 | 48 game s |
| 富营养的滑动者 (360) | 15 % 神经 | 54 | 19 | 38 game s |
| 骨海漂流体 (250) | 20 % 侵蚀 | 50 | 20 | 40 game s |
| 掠海漂移体 (500, BAT 4) | 50 % 侵蚀 | 250 | 4 | 16 game s |
| 元核孽生者 (400, season) | 5 %/s 神经 in r 2.5 (season override; PRTS's base page: ATK 500, 10 %) | 20 / s | — | 50 s |
| 深溟巢涌者 (140) | 5 % ATK 神经 per damage output | ≈ 7 / s | — | ≈ 143 s |
### 19.6 Element damage of the player side (#3, operator side) — `sim/content/kits/tier{2,3,5,6}.js`, `tokens.js`, `items/battle.js`
- **Audit**: only these deal 元素损伤 from the player side in 下半 — 盟约·辅助干员 (band 优等生), 菲莱, 烛煌, 妮芙 (T5 / hidden T6), 余, 塑心, 炎佑 (炎 ×6), 灼燃维式重锤 (+ 蒸汽之心); no bond, band, garrison, 机变 card or device deals any. Every number matched its blackboard (character_table at the chess status, battle_equip_table modules); the discrepancies were form, trigger and scope. "N%攻击力的…损伤" = N × ATK, "伤害N%的…" = N × the damage just dealt.
- **The fix that matters most**: 盟约·辅助干员's 迭代元素 used to pick the "first element not bursting" — a burst locks every gauge, so she only ever filled 神经. Now every damage she deals (HP > 0; PRTS corrects the timing to "造成伤害时") attaches 18 % ATK of 神经 → 灼燃 → 凋亡 (`PITHST_ELEMENTS`); the element applied first bursts, so alone she bursts 神经 and in a team completes the 灼燃 / 凋亡 gauges 余 / 塑心 build — in the reviewer's runs the team's first 灼燃 / 凋亡 burst came ≈ 40 % sooner (9.0 s vs 16.0 s; 5.0 s vs 8.2 s). Teams without her are unchanged. [ASSUMED] the three apply together (the reading of the text).
- **Killing blows**: element riders inside `damaged` hooks run before `battle.kill`, while the target is `alive` at 0 HP; a lethal hit used to burst the corpse (a second fatal, 烛煌 熔点引爆's heal and 350 % hit, 妮芙's stack). The riders check HP left and, since the integration, `applyElement` refuses it (§19.5).
- **塑心 S2 (integration)**: PRTS "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" — the rider is a late `hit` handler, so the 凋亡 lands (and may burst) before the damage, a killing blow included; a hit dodged or absorbed after that still carried it [ASSUMED]. **纯烬 氤氲** is keyed per source (`agoat2:mist:<id>`): two 纯烬 keep their own stacks, ATK and heal source.
- Tests: `test/content/playtest5_elements.test.js` (9), `test/content/playtest5_followups.test.js` (塑心 S2 order, 氤氲 per source), `kits_t1t2.test.js` (1_15, 哈洛德 with 侵蚀), `kits_t3.test.js` (菲莱).
| Source (normal / elite) | Official | v2.3 | Now |
|---|---|---|---|
| 盟约·辅助干员 迭代元素 | 18 % ATK each of 神经(优先)/ 灼燃 / 凋亡 per damage dealt; RIT-X 21.24 % vs 精英 / 领袖, talent only | per attack hit (dodged too), 神经 only | **fixed**: all three per damage > 0 |
| 菲莱 S2 冥河诅咒 | when attacked (cd 2 s): 110/140 % ATK arts + 25 % ATK 凋亡 to ground enemies in x-4 (3×3) | radius 1.5 | **fixed** (3×3, body-aware, 近地悬浮 = air) |
| 菲莱 PRP-X | "阻挡敌人时,自身造成的元素损伤提升15%" on every fill she deals | S2 blast only | **fixed** (all fills, a hammer too) |
| 受到的元素损伤降低: 菲莱 神河谕使 / 哈洛德 我即军营 / 纯烬 火山灰疗愈 | −10 % (+2 SP on 凋亡 even for 0) / −12/15 % for allies over half / −12/14 % (×talent_scale in S3); before the 损伤屏障 | the 元素脆弱 multiplier (also cut 元素伤害) | **fixed**: `elementHit` at priority 20 |
| 哈洛德 trait / T1 | "元素损伤最严重" and the over-half checks | 侵蚀 ignored | **fixed** (侵蚀 counts) |
| 纯烬 氤氲 | per stack / s: HP 10 % ATK, element 10 % × trait ratio (5/6 % ATK), cached ATK | element 10 % ATK (2×) | **fixed**; per source (integration) |
| 塑心 S2 安魂的弥撒 | self + top-ATK ally: each damage + 15/20 % of her ATK 凋亡, before the damage | after the damage, skipped on a killing blow | **fixed** (integration) |
| 塑心 S1 / T1 / T2 / RIT-X / RIT-Y | 85/95 % ATK 凋亡; 10 % ATK/s; 精神逆构 ×1.2 (elite ×1.33 + 5 % 元素脆弱 in the burst); ×1.18 vs 精英 / 领袖; RIT-Y 音乐家的旅程: field-wide ×1.2 + 150 元素伤害/s during the burst, 10 % 元素脆弱 in range | same | OK |
| 余 T1 / S3 / S1 / T2 / PRP-X | 40 % ATK arts + 12 % ATK 灼燃/s on blocked enemies; 火墙 6/7 %; 30/40 % ATK on the attacker; T2 1.5 % max HP of HP and element per s with ≥ 4 operators on the field (elite ≥ 2; elite ≥ 4 also +14 % arts vs 灼燃 bursts); ×1.15 while blocking | same | OK |
| 烛煌 S1 / S2 / S3 / T1 / PRI-X | 30 % of the damage as 灼燃; +60/70 % ATK 元素伤害 on burst targets; 熔点引爆 350 % + 12 % heal; ×1.1 vs burst targets | same | OK |
| 妮芙 S2 / T1 / PRI-Y | 18/22 % of her arts damage as 凋亡; 失魂 40 % ATK 元素伤害/s (PRI-Y 烦恼问诊匣: every 0.7 s, SP +0.2/s) | same | OK |
| 炎佑 / 灼燃维式重锤 | 20 % ATK 灼燃 per damage, 20 % 元素脆弱 aura r 1.5 / 10 % of the arts damage (蒸汽之心: own hammer ×2) | same | OK (元素脆弱 now raises bursts, §19.5) |
| any rider on a killing blow | a dead unit takes no element | burst the corpse | **fixed** (hasHp + pipeline guard) |
### 19.7 Maps: blower machines, boss-field deployment (#6, #7) — `render/board3d/layout.js`, `render/tiles.js`, `render/app.js`, `server/match/board.js`, `Match.startRound` / `bossGroupOf` / `deployFieldOf`, `ui/gameLogic.js`, `ui/fallbackField.js`
- **#6 cause**: the 3D board builds only the current view's area, and for the normal / 联防 views it stopped at row 12. act2 m01's own blowers (#001 / #002, the partner's #101 / #102) stand on the separator row 13 facing DOWN into rows 12–10 (level_act2autochess_m01 predefines: (13, 5/9/13/17) and (6, 5/9/11/15), all DOWN, none hidden), so they were never built; the Pixi airflow streaks and the sim were right. **Now** the normal / 联防 field rects are rows 6–13 and one device rule serves both boards: every active device standing on a built tile is drawn (3D `stageDevices`, 2D `_stageDevices` minus its faded margin), the boss field's (rows ≤ 6, `BOSS_WALL_ROW`) only in the views that build the boss field. Normal / prep: (13,5) and (13,9) above the lanes; 联防 adds (13,13) and (13,17); 最终攻势 / 隐秘核心: (6,5), (6,9), (6,11), (6,15).
- **#6, the boss field's machines under the bench** (integration QA): the boss field's row-6 blowers (6,5) / (6,9) stood on the wall under the bench in the normal / 联防 views (v2.3 and the first integration), visible in the user's R7 screenshot between the bench and the shop bar. An official player never sees that wall in a normal round: the official shop camera (`left_shop_camera_param`, = the remake's prep camera at 16:9) puts row 6 at ≈ 818–941 px of 1080, under the shop bar (top 813 px); the normal battle camera shows nothing below row 8; and the shop-collapsed prep view (`left_prepare_camera_param`; PRTS's in-game capture of act2 m01 at 1280×720) ends at row 6's far edge — the remake's shop bar leaves the left part of that band open, and its prep camera stays when the shop collapses. So the boss field's devices are drawn with the boss field only; whichever way "反而在关底吹风机的贴图又出现了" is read (the boss round shows its machines — still true — or they showed under the bench — no more), the normal views now match the official. Row 6 itself stays built (the wall under the bench). Unchanged (cosmetic, pre-existing): the official normal view also shows the partner's half beside the own field (its row-13 machine (13,13) at the top right); the remake builds the own half only.
- **#7 cause**: every legality check of a boss round's prep read the normal field (board (10–12, 8) = tile_forbidden) — `board.js buildDeployMap`, the client's `canPlace` / highlights, the bot and the invariants — while the pieces stand on the player's half of the boss field, where (3–5, 8) and (3–5, 12) are tile_fence_bound (low ground, buildable ALL, fly-only passable). **Now** `buildDeployMap(stage, { field })` takes `'normal' | 'bossL' | 'bossR'` (board row = boss-field row + `BOARD_ROWS_ABOVE_BOSS` 7; the right-hand player mirrored col c → 20 − c, research 09 §1.2 ConvertChessPositionInfoToBossMap); `Match.bossGroupOf` is the one pairing lookup (`deployFieldOf`, `nextEnemiesFor`, the bot); `Match.startRound` plans the round's enemies before `PlayerState.startRound`, so R14 → R15 re-checks boss-half pieces on the boss field (they used to be withdrawn with 地形变化; rngWaves is used only there, no random stream changes); a quit before the boss fight re-pairs and re-checks at once; the client's `gameLogic.deployFieldOf` feeds `placementContext({ field })`. Operators on the fence tiles hit the huge boss (§19.4); ground enemies cannot walk onto those tiles.
- **Audit** (all 11 stages × normal / 联防 / boss L / boss R, server = client = builder deployTiles = the raw level files): the only wrong tiles were act2 m01's six boss tiles; on every stage normal-legal ⊆ boss-legal and boss L = boss R, also under every map card, so R13 → R14 and R14 → R15 move nothing. act2 m01 now deploys 13 melee + 3 ranged-only tiles on the normal board and 16 + 3 on each boss half. Devices: 3D = 2D for every stage × view; act1 m05's 特制水上平台 are ×64 (16 per normal half, 32 on the boss field). The DOM fallback board (`ui/fallbackField.js`, no WebGL / `?render=fallback`) draws the player's boss half in the boss prep too — `screens/game.js` now hands it the `bossPrep` camera, which it maps through `gameLogic.fieldTile` on the own board's layout (integration QA: it drew the normal field's walls under legal highlights). Limitations: `Match.unitStats` previews on the normal field (stats only); 射击台 stays ranged-only [ASSUMED].
- Tests: `test/match/playtest5-deploy.test.js` (incl. boss → boss R14 → R15 and the fallback board), `test/render/playtest5-maps.test.js` (incl. the boss field's devices only in the boss views), `test/ui/playtest2.test.js`; browser (opt-in) `test/render/board3d.browser.test.js` (area key '6,13,0,10').
### 19.8 Settings gear; the shop bar over the bench on phones (#8, #9) — `ui/gameComponents.js`, `render/projection.js`, `ui/fieldHost.js`, `render/app.js`, `css/devices.css`
- **#8**: the ⚙ was a hand-written SVG path with uneven teeth on a rim that was not round (the extracted autochess UI has no gear sprite; 交流 is the official `emoji_btn`, 📖 / ⛶ were clean). `gearPath()` now generates a regular 8-tooth gear (tip r 10, root r 7.4, hole r 3.3); on phones the corner glyphs are `max(.28rem, 17px)`, half their 34 px buttons (were 11 px).
- **#9, the user's viewport**: the screenshot is 2772×1272 px with the page right of a 141 px cutout band; the page's own markers give DPR ≈ 3.48, so **756×366 CSS px (≈ 2.07:1)** — the mock rendered there puts the exit and 准备就绪 buttons on the screenshot's pixels within ±1 px.
- **Cause**: the official shop camera (`left_shop_camera_param`, boss `*_boss_shop_*`) fits the prep area exactly at 16:9 (bench near edge 811 px vs the shop bar at 813 px at 1920×1080), while the remake's HUD is in rem with a 40 px floor (css/theme.css), so below 432 CSS px of height it is relatively taller: v2.3 left the worst bench pad 54 % free at 756×366 (Final Assault prep 49 %), 71 % at 844×390, 49 % at 800×360.
- **Now**: the view receives the HUD bands, `hudBands(kind, size)` (top = the HUD layer's safe-area top + 2.16rem, the bond strip; bottom = 2.64rem + 3 px, the shop bar; the rem values copy the CSS, which a test checks), and `presetCamera('prep' | 'bossPrep', …, { hud })` runs `clearHud` on the band from the bench's near edge to the back row: the official camera when it fits (0.5 px tolerance), else the smallest pan, else a zoom-out about the centre — a 2D pan / zoom of the image, so picking, the three.js camera and flights stay consistent. Every desktop prep camera is unchanged; the bar stays on the bottom edge (§18.1); the collapsed shop keeps the camera (nothing jumps) — the official client moves to its closer `left_prepare_camera_param` there, so on a phone the remake's collapsed prep board stays at the clearing zoom (×0.844 at 756×366; integration QA, an optional follow-up: `shop: !collapsed` for the own prep camera with a 0 bottom band). Accepted: an armed shop card rises 4 px above the bar on a phone and covers ≈ 3 px of the pads' near corners (≈ 11 px under the official camera at 1920×1080). [ASSUMED] the bond strip's bottom (2.16rem, measured 2.14–2.15rem); the zoom anchor.
- Tests: `test/render/projection.test.js` (HUD clearance suite), `test/ui/playtest5-ui.test.js`; browser (opt-in, `SP_E2E=1`) `test/ui/playtest5-ui.e2e.test.js` (per-tile coverage at 16 viewports, the armed card, the gear).
| Viewport (CSS px) | Worst bench pad free, v2.3 → now (prep / Final Assault prep) | Camera now | Tile px |
|---|---|---|---|
| **756×366 (the user's Android)** | 54 → 100 % / 49 → 100 % | ×0.844 | 41.5 → 35 |
| 798×366 + 41 px inset (same phone over the cutout) | 53 → 100 % / 49 → 100 % | ×0.844 | 41.5 → 35 |
| 844×390 (19.5:9, with or without notch) | 71 → 100 % / 66 → 100 % | ×0.905 | 44.2 → 40 |
| 800×360 (20:9) | 49 → 100 % / 45 → 100 % | ×0.828 | 40.8 → 33.8 |
| 915×412 / 914×411 / 960×411 | 83 → 100 % / 83 → 100 % | ×0.95 | 46.7 → 44.5 |
| 1920×1080, 1680×1050, 1560×1040, 2560×1080 | 100 % | official | unchanged |
| 1280×720 | 100 % / 96 → 100 % | official (Final Assault prep: pan up ≤ 2.8 px) | unchanged |
### 19.9 至简's price (#11) — `sim/content/garrisons/meta.js SERVER_CHESS_PRICE`, `server/match/effectsMeta.js dispatch`
- **What the user saw**: 至简 (Ⅲ) cost 2 资金; the user remembered 1. His 特质 garrison_13 reads 购买价格为1, but `bb.price` is 2, and the handler set the price to `bb.price` ("blackboards are authoritative").
- **Official**: two chess carry this 特质 (`effectType` SERVER_CHESS_PRICE) and both texts say 购买价格为1 — 至简 (Ⅲ, tier price 3, `price` 2) and 红豆 (Ⅰ, tier price 2, `price` 1, hidden in 下半). Only "`bb.price` is the discount off the tier price" gives the text's 1 for both (3 − 2, 2 − 1).
- **Change**: the handler lowers the price by `bb.price` (`ctx.modifyPrice(-p)`), and `EffectDispatcher.dispatch` runs the priced chess's own 特质 first on `onPrice` (before globals / band / bonds), so 远见's discount (−1, never below 1) and strategy caps act on the lowered price: 至简 stays at 1 with 远见 150 instead of dropping to 0. The shop card keeps `basePrice` 3, so the price shows in the discount tone. docs/META.md §2.2 / §2.3 say the same.
- Tests: `test/content/garrisons_meta.test.js` (every SERVER_CHESS_PRICE owner: tier price − `bb.price` = the text's price = `priceOf`; buying 至简 costs 1; a global modifier already sees 1; 远见 150 leaves 至简 at 1).
+30 -8
View File
@@ -11,7 +11,8 @@ server/match/
gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer
(data/tuning.json, §3.1)
pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls
board.js placement legality from the stage legend, slot helpers, deployment order
board.js placement legality from the stage legend on the deploy field (own board / boss half), slot helpers,
deployment order
bondsMeta.js bond counting modes, tiers, 调和 / 独行 / 助力 / 绝技, layers
effectsMeta.js MetaRegistry + EffectDispatcher + the handler ctx (this document, §2)
builtinMeta.js engine built-ins (consume-on-equip items, Arts, EffectRefs used by 机变 defaults)
@@ -51,6 +52,20 @@ LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all hum
→ RESULT (m.result to every human, then onEnd once)
```
**Deploy field (user playtest #5 item 7).** From the ROUND_START of a boss round (最终攻势 / 隐秘核心) a player deploys on
its half of the boss field (`Match.deployFieldOf` → `'bossL'`, or `'bossR'` for the second player of a seat pair — the
same pairing as the fields, `bossWaves` / `Match.bossGroupOf`, planned in `startRound` BEFORE the players' round start
so R14 → R15 never re-checks a boss-half board against the normal field, and re-paired — with an immediate re-check —
when a teammate quits before the fight): `board.js buildDeployMap`
reads the stage tile (r − 7, c) / mirrored (r − 7, 20 − c) under every board tile (r, c) (official
ConvertChessPositionInfoToBossMap, player_map_ud_offset 7) and that half's devices under the player's overrides. Board
coordinates never change (g.move, the battle input). A change of the deploy field re-checks the board like a terrain
change (`PlayerState.deployMap` → `_evictIllegal`; the read-only checker `invariants.js` uses the pure
`Match.deployMapFor`); on the data's 11 stages everything legal on the normal board is legal on both boss halves (and
the two halves have the same classes), and the boss halves add act2 m01's fenced tiles (board (10–12, 8)), which stay
legal from R14 into R15. The client mirrors it
(`ui/gameLogic.js deployFieldOf` / `placementContext({ field })`); the bot plans on the same map.
| Timer (real s, × `opts.timerScale`) | Value |
|---|---|
| INFO_CHECK | `config.timers.infoCheck` 25 |
@@ -215,8 +230,9 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa
| `onLayers` | bond layers were added (prep or battle gains) | `{ bondId, from, to, reason }` (milestones: 维多利亚 25, 远见 10, 奇迹 100 …) |
Dispatch order per player: `global` → `band` → `bond` (data order) → garrisons (board in deployment order, then hand)
→ equipped items → EffectRefs (insertion order). Every call is isolated with try/catch (the error is logged once and
counted in `match.dispatcher.errors`); nested dispatches are capped at depth 6.
→ equipped items → EffectRefs (insertion order). `onPrice` runs the priced chess's own 特质 first (购买价格为N sets the
price that 远见's discount and the strategies' caps then act on). Every call is isolated with try/catch (the error is
logged once and counted in `match.dispatcher.errors`); nested dispatches are capped at depth 6.
### 2.3 Garrisons (特质)
The dispatcher calls `handler[hook] ?? handler.run` only on the hook of the garrison's `eventType` (a handler may widen
@@ -229,7 +245,7 @@ that per garrison with `garrisonHooks(garrison) → hook[]`, e.g. "<进入休整
| `SERVER_PREP_FIN` 休整期结束时 | `onPrepEnd` | same |
| `SERVER_REFRESH_SHOP` 刷新时 | `onRefresh` | same |
| `SERVER_CHESS_SOLD` 售出时 | `onSold` | the sold piece |
| `SERVER_PRICE` 购买价格 | `onPrice` | the chess in the priced slot (`ctx.source.where === 'shop'`) |
| `SERVER_PRICE` 购买价格 | `onPrice` (first) | the chess in the priced slot (`ctx.source.where === 'shop'`); SERVER_CHESS_PRICE `bb.price` is the discount off the tier price (至简 3 − 2 = 1, 红豆 2 − 1 = 1: both texts say 购买价格为1) |
| `IN_BATTLE` | — | battle side (server/sim/content/garrisons.js `install`) |
```js
@@ -423,9 +439,12 @@ chosen by most units on the field (downed included) > an active bond > most stan
units > active bond > Σ active layers > standing > seat (LP plays no part), the first one on the right-hand field
(colOffset +8, where escaped_multi enters), the other colOffset 0; the escaped template of that size routes the leaked
enemies by slot class; helpers' operators carry
`{ hpPct, sp, skillActive }` from `unitsEnd` ("阵地以其当前状态"); an operator dead at the end of the helper's own combat
stays out of the 联防 battle with its summons [ASSUMED: the sim has no "start undeployed with a running redeploy timer"
input]; `flags.layerGainsEnabled = false`; time limit = the round's combat limit.
`{ hpPct, sp, skillActive }` from `unitsEnd` ("阵地以其当前状态"); an operator knocked out at the end of the helper's own
combat carries `{ down: true }` (PRTS 卫戍协议/帮助: "部署完成后…上一阶段为退场状态的干员强制退场"): deployed, then forced out
at once, it lies on its tile with the redeploy ring and redeploys like after any knock-out (docs/SIM.md §1.1; user
playtest #5 item 2 — it used to stay out and vanish); its timer is its full redeploy time [ASSUMED: the official setup
carries only hp / tech per operator], with the redeploy-time effects that start with the battle (机变 征召); summons are
fielded as the board has them; `flags.layerGainsEnabled = false`; time limit = the round's combat limit.
Every enemy still alive at the end (leaked again, or never spawned before the limit) costs its **source** player 1 LP.
A client-run 联防 result may bill a survivor only to a leaker who sent that enemy in — a split / summon only to a leaker
who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉 4 …; fields.js offspringPerParent).
@@ -517,6 +536,9 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
* 中途退出 = elimination at once (research); a quitter's operators already fighting in a shared field (联防, boss
field) finish that battle, its LP already merged into the team LP stays there.
* After the LP merge (Final Assault) the per-player LP shown is a share of the team LP ∝ the LP each player brought in.
* A 联防 helper's operators dead at the end of its own combat do not take part in the 联防 battle.
* A 联防 helper's operator knocked out at the end of its own combat is deployed and forced out at once (PRTS 强制退场,
§4) with HP 0, and redeploys after its full redeploy time: no timer carry (the official setup carries only hp / tech
per operator), forced out before `battleStart` (its timer re-read after it, so 征召's −50 % applies; 征召's row check
does not count it), no knock-out hooks (`kill`, 'killed' deaths) a second time.
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; its elite goes to the hand,
overflowing into temp like a prep merge's (temp pieces that arrive after the prep wait through the next prep).
+7 -4
View File
@@ -18,7 +18,7 @@
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括资金、干员、盟约层数、战术特训(悬赏敌人)等。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条右侧的圆形图标是**元素损伤**:干员为带图标的彩色圆盘,敌人为小号纯色圆盘,白色圆环是剩余元素值;爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),期间所有元素都不会累积也无法被治疗,结束后清零。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条右侧的圆形图标是**元素损伤**:干员为带图标的彩色圆盘,敌人为小号纯色圆盘,白色圆环是剩余元素值;爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
@@ -66,12 +66,13 @@
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。
- 能在休整期摆放的召唤物只有天赋召唤物(海嗣、狼群、流形);赫默的医疗无人机、巫恋的诅咒娃娃这类技能召唤物只在技能发动时出现。
- 部署顺序是**从上到下、从左到右**,最后部署的干员嘲讽等级最高;部署费用不够时后面的干员会晚一点上场。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**近战干员**总能攻击自己阻挡的敌人(即使它不在攻击范围内),并优先攻击它;站在近战位的远程干员也能阻挡,但只打攻击范围内的敌人、不会优先打自己阻挡的敌人(挡在身后、范围外的敌人打不到)。
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿、迷彩的干员只会被它阻挡的敌人攻击。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
## 5. 联防(同盟)
某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(保持作战结束时的生命与技能状态,已倒下的干员不参加),迎击其他玩家漏掉的全部敌人。
某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(保持作战结束时的生命与技能状态;作战结束时已被击倒的干员在原位倒地,按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。
- 参加者按官方规则挑选:场上单位最多 > 有已激活的盟约 > 未倒下的单位最多 > 座位靠前(与 LP 无关);两人参加时,排在前面的一位(再比较激活盟约的层数总和)在右半场「率先迎敌」。
@@ -94,6 +95,8 @@
## 7. 最终攻势与隐秘核心
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**,看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利,LP 归零即失败。此阶段盟约不叠层。
- 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。
- **巨型领袖**(假想敌:胄 / 管、盐风主教昆图斯、阿利斯泰尔、“萨米的意志”)的受击判定是一片约 5×3 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。
- **隐秘核心**(第 15 回合,仅险境及以上):打倒领袖后,若全队激活的盟约层数总和 **> 1200**(独立 > 350)且 LP > 1,进入隐秘核心,挑战更强的领袖。失败不影响本局的通关。
## 8. 表情与观战
@@ -111,7 +114,7 @@
- 前期优先凑出一个能激活的盟约,比零散买高阶干员更稳;同名 3 张会自动晋升并带来一次免费的高阶招募。
- 资金不能留到下回合:没用完就刷新或升级;想要的卡没钱买时先**冻结**。
- 升级价格每回合自动 −1,不急的话可以等一两回合再升。
- 休整期看看右上角的敌人预览,有飞行单位就留好对空(远程)干员。
- 休整期看看右上角的敌人预览,有飞行单位就留好对空(远程)干员;**近地悬浮**的敌人(掠海漂移体、吉兆飞鳞)也算空中单位,近战和只打地面的干员(如迷迭香)打不到,把它晕眩、冻结或睡眠后才会掉下来(掠海漂移体掉下来后一直爬行,模型也换成爬行姿态)。
- 同盟模拟里别和队友抢同一系干员(卡池共用),分工凑不同的盟约更容易。
- LP 只剩 10 左右时(一回合最多扣 10)要优先保完美作战。
- 联防只让完美作战的队友上场,所以稳定清怪的阵容对全队都有价值。
+144 -33
View File
@@ -15,6 +15,7 @@ server/sim/
buffs.js Buff model, mod keys, status catalogue
damage.js damage / heal pipeline, shields, dodge, element gauges
targeting.js range grids (rotated by the unit direction), target filters, priorities
body.js where a unit can be hit: huge units' hit rectangles — the helper of every range test on enemies
dir.js the 4 deploy directions: vectors, rotation, mirror (shared with server/match and browsers)
projectiles.js projectile flight & impact
skills.js SkillRuntime (SP, charges, triggers, kinds, SkillSpec)
@@ -57,9 +58,14 @@ b.fieldMeta(); // { fieldId, kind, rect, stageId, units: Uni
```
Construction creates every ally unit (not yet deployed) and installs content (kits, then domain modules). The first
`step()` (or `b.start()`) spawns stage crates, deploys all board units for free — per player, **top→bottom then
left→right** (right boss side: right→left in field columns) — firing `deploy {initial:true}` for each, then
`battleStart`. Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
`step()` (or `b.start()`) spawns stage crates, deploys all board units for free — per player the **operators top→bottom
then left→right** (right boss side: right→left in field columns), **then the summon pieces** the same way (PRTS
卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物") — firing `deploy {initial:true}` for each, forces
out the operators that enter knocked out (`carryState.down`, 联防 — §1.1), then fires `battleStart`. Every summon that came in during the initial deployment (also one an operator's deploy brought along)
then ranks after all the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field)
the players' fields deploy side by side: the i-th operators of all players come in together (in `players` order), then
the summons the same way, and the summons rank after the operators of all players **[ASSUMED]** (PRTS describes one
field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
**Tick order:** scheduled callbacks → spawns → DP → buffs (+HP regen) → enemies (attack, move, block) →
enemy tile index → allies (skill tick, attack) → projectiles → auto-redeploys → boss pool sync → `tick` hook →
@@ -98,6 +104,13 @@ directions on an open field). 余 S3's fire wall runs through his tile perpendic
RIGHT / LEFT, his row facing UP / DOWN; fx `firewall.axis` = `'col'|'row'`).
`carryState: { hpPct, sp, skillActive }` restores unite helpers (HP ratio, SP; `skillActive` restarts a timed skill for a
fresh duration/ammo **without spending a charge** — `unitsEnd` reports `sp: 0` while a skill runs, so pass it through as is).
`carryState: { down: true }` = an operator knocked out at the end of the helper's own combat (PRTS 卫戍协议/帮助 "上一阶段为
退场状态的干员强制退场", user playtest #5 item 2): `start()` deploys it with everyone (initial `deploy` fires), then — before
`battleStart` — withdraws it with reason `FORCED_EXIT` (constants.js `'forcedExit'`) and HP 0 (the end-of-phase HP ratio, as after
`kill`): it is down on its tile (`isDown`, b.snap `down`) with its full redeploy timer and redeploys by the normal rule; no `kill` hook, no `'killed'` death (knock-out
effects such as 深海 / 不屈 revives or 崇高牺牲 layers fired in its own combat), no `deaths` count. Right after `battleStart`
its timer is re-read (`deathAt + respawnTime × persist.redeployMul × s.redeployMul`), so a redeploy-time effect switched on
by a `battleStart` handler covers it like a later knock-out (机变 征召 −50 %; 征召's row check does not count it [ASSUMED]).
### 1.2 Enemies, routes, ownership
@@ -119,9 +132,33 @@ the decorative crate on it — act1 m03 (11,5); test/sim/pathing.test.js). If cr
and destroys it. FLY legs fly straight between checkpoints. `disappear` hides
the enemy (untargetable, not in snapshots), `wait` pauses, `appear` teleports.
**Blocking** (`Battle._checkBlock`, official contact rule — PRTS 游戏数据基础 §阻挡半径, 作战机制 "中点判定 … 案例: 阻挡";
user playtest #5 item 4): an unblocked, blockable enemy is blocked by an ally (or device) whose centre is within its
block radius of the enemy's position — `constants.js BLOCK_RADIUS`: ground 0.7071 (compared as d² < 0.49999037, the
tile's circumscribed circle), air 0.8944 (blockFly units against flyers), devices 0.4472 — while that blocker has free
capacity for the enemy's `blockWeight` (data `blockCnt`). It is checked every tick for every unblocked enemy, moving or
not: an enemy that overlaps an operator when its blocker dies / is withdrawn / is stunned, or when the operator's
blocked enemy dies, is taken over at once; an enemy that finds no room walks on (pass-through). Several blockers in
contact → the nearest [ASSUMED]. A head-on enemy therefore stops at contact, ~0.71 tile from the blocker's centre, on the
tile in front of it (PRTS 作战机制: a blocked enemy's collider does not enter the blocker's tile; the official few
hundredths of a tile of deceleration are not modelled), and a **melee** blocker (data `position` MELEE, `targeting.js
meleeUnit`: 要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first
("可以选择且优先选择阻挡单位", PRTS 选择器; 索敌的概念 "我方索敌优先级:阻挡(近战限定)" — `Battle.blockedTargets`, used by
`ai.js acquireTargets` and the skills' DEFAULT trigger, and `sortEnemyTargets`; "自身这格内" rules name the blocked enemies
separately — 瑕光 S2, PRTS 备注). A ranged operator standing on a melee tile blocks, but targets by its range alone: no
blocked-first priority, nothing out of range, no DEFAULT cast for the enemy it holds (PRTS 索敌的概念 "远程位干员通常而言不会
优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人").
**Enemy attacks:** blocked melee enemies hit their blocker; enemies with `rangeRadius > 0` **and `applyWay` ≠ `MELEE`**
attack allies within the radius (a MELEE enemy only ever hits its blocker — data/enemies.json already zeroes their
`rangeRadius`, the engine enforces it for any source; content may set `enemy.profile.melee = false`) (blocker → highest taunt → latest deployed; stealthed and `untargetable` allies are skipped for ranged shots)
`rangeRadius`, the engine enforces it for any source; content may set `enemy.profile.melee = false`). Target order
(targeting.js `sortAllyTargets`, PRTS 作战机制 索敌 "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被攻击)"):
its blocker → highest taunt level → latest deployed (`aggroSeq` = the deploy order: a redeploy or a mid-battle summon is the
latest). `enemy.profile.canTarget(ally)` (content: 萨卡兹枯朽战车 "只攻击位于低地的我方单位,且不会攻击飞行单位", 掠海漂移体 / “萨科塔之眼” 不会攻击飞行单位 …) filters the candidates before the order;
a special priority (假想敌:铳 / 昆图斯 highest DEF, 假想敌:胄 highest / lowest ATK, “自在” nearest …) sorts by its key and
breaks ties by taunt, then latest deployed (`aggroCmp`); `untargetable` / sleeping allies and devices are never targets;
a stealthed ally (隐匿 / 迷彩, 排气格栅) only for the enemy it blocks — our operators keep 隐匿 while blocking (PRTS 作战机制
§隐匿; 索敌的概念: a blocked enemy "强行无视对方可选性" attacks its blocker)
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Content can take over an
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
@@ -204,13 +241,35 @@ extraRangeKeys (content extra targets, `battle.setExtraRange`), blocking[] (alli
('WALK'|'FLY' enemies), profile, skill (SkillRuntime), kit, items (itemIds), lpr/mods/tag/bounty/sourcePlayerId (enemies),
stats {dmg,kills,heal,taken,attacks}, mem {} and trait {} (free scratch space), persist {redeployMul, …}.`
Getters: `s` (aggregated stats), `maxHp`, `atk`, `hpRatio`, `sp`, `spMax`, `canAct`, `isFlying`, `statusFlags` (UF bits),
`dmgType`, `weight`.
`dmgType`, `weight`. `isFlying` = an **air unit** for every targeting / ground-only rule: `motion` FLY, or an enemy with
flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate` (浮空 "变为空中单位"); movement and pathing read `motion`
(a hovering enemy keeps walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`);
`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1).
**Hit areas (`body.js`, user playtest #5 item 10).** A regular enemy is a point: in a grid range when the tile of its
position (`round(y)`, `round(x)`) is a range tile, in a radius when its position is (DESIGN §3). A huge enemy (巨型单位:
`enemy.hitArea` from data/enemies.json `hitArea` — 假想敌:胄 / 管 / 盐风主教昆图斯 / 阿利斯泰尔 / “萨米的意志”, PRTS
"受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0") is hit anywhere on a rectangle `w` × `h` tiles centred on its position
moved `dx` columns right / `dy` rows up: it occupies every tile the rectangle overlaps (`bodyKeys` — 5 × 3 tiles; PRTS
作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"), and radius rules measure to the rectangle (`bodyDist`, 0
inside) [ASSUMED: PRTS 作战机制 calls collider tests the most common one] — except **splash around a struck / marked
target**, a 中点判定 (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"): `enemiesInRadius(x, y, r, true)` counts
every enemy, a huge one too, by its position (the engine's profession splash and 炮手 aftershocks, and the kits' splash
centred on an enemy — 焰影苇草 S3's 灼痕 burst, 新约能天使 火力电台, 仇白 S1 留羽). Its position stays its 判定中心 (projectiles fly to it; its own
attacks, and splash centred on it, measure from it); body size plays no part in blocking or movement: every huge leader is 自缚 + 无法被阻挡 (PRTS 天赋; content/bosses.js
`SELF_BOUND` gives it a persistent `noMove` + `unblockable` buff at spawn — it stands where it spawns, its route is never
walked, so the rectangle stays on the pipe block). Content tests enemies only through the engine
queries above or `body.js` (`bodyInKeys`, `bodyOnTile`, `bodyInRadius`, `bodyDist`, `bodyTileReach`; wave-B modules via
`support/index.js` `onKeys` / `inRange`), never `rangeKeySet.has(tileKey(enemy))`; distance-scaled numbers (farther
targets take more damage, pull strength), movement, pathing, terrain under the enemy, tile-entry tracking, placement
heuristics and the range keys taken from blocked enemies (a huge enemy is never blocked) keep the position.
`unit.s` (lazy, recomputed after any buff change): `maxHp, atk, def, res, aspd, bat, interval, blockCnt, moveSpeed,
rangeExtend, baseRangeExtend (its permanent part: persist + never-expiring buffs), massLevel (base + ΣmassFlat, ≥ 0 —
`unit.weight`), maxTargets (+n), taunt, dodgePhys, dodgeArts, defIgnoreFlat/Pct, resIgnoreFlat/Pct, dmgDealtMul,
physDealtMul, artsDealtMul, dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, elemTakenMul, healingDealtMul,
healingTakenMul, atkScaleMul, spRecovery, spCostFlat, redeployMul, hpRegen, shield, flags{…}`.
physDealtMul, artsDealtMul, dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, elemTakenMul (元素损伤倍率: gauge fills),
elementalTakenMul (元素脆弱: 元素伤害), healingDealtMul, healingTakenMul, atkScaleMul, spRecovery, spCostFlat, redeployMul,
hpRegen, shield, flags{…}`.
Aggregation: `ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul`; `res = clamp((base + ΣresFlat) × ΠresMul, 0, 100)`;
`aspd = clamp(base + Σaspd, 10, 600)`; `interval = bat × (1 + ΣbatPct) × 100 / aspd`; `moveSpeed = (base + ΣmoveFlat) × ΠmoveMul`;
@@ -233,10 +292,11 @@ mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield,
defIgnoreFlat defIgnorePct resIgnoreFlat resIgnorePct dodgePhys dodgeArts spRecoveryFlat maxTargets taunt hpRegen
hpRegenRatio spCostFlat moveFlat massFlat` (重量 levels: 失重 = `massFlat: −1`; never edit `base.massLevel`);
multiplicative: `atkMul defMul hpMul resMul moveMul dmgDealtMul dmgTakenMul physTakenMul artsTakenMul trueTakenMul
elemTakenMul healingDealtMul healingTakenMul spRecoveryMul redeployMul atkScaleMul physDealtMul artsDealtMul`.
elemTakenMul elementalTakenMul healingDealtMul healingTakenMul spRecoveryMul redeployMul atkScaleMul physDealtMul artsDealtMul`.
A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1).
**Flags:** `stun freeze sleep silence disarm stealth invulnerable unblockable levitate fear cold reveal bind noHeal
untargetable blockFly noMove noSp burstLock hidden attract`. `taunt: true` as a flag counts as +1 taunt level (DESIGN §5.3).
untargetable blockFly noMove noSp burstLock hidden attract float noDisplace` (`float` = 近地悬浮 (an air unit,
`Unit.isFlying`), `noDisplace` = 失衡免疫 (`displace()` moves nothing)). `taunt: true` as a flag counts as +1 taunt level (DESIGN §5.3).
**Statuses** — `battle.applyStatus(target, key, { duration, source, value, force, refresh, point })` (returns true if
applied); honours enemy/op immunities (`stun`, `silence`, `sleep`, `frozen`, `levitate`, `feared`) unless `force`; fires
@@ -249,22 +309,22 @@ if it outlasts it, resumes when the strong one expires (pass `refresh` to opt ou
| key | effect | value |
|---|---|---|
| `stun` | cannot act / move; **a stunned operator blocks nothing** (its blocked enemies walk on) | – |
| `stun` | cannot act / move; **a stunned operator blocks nothing** (its blocked enemies are released: taken over by another operator in contact with room, else they walk on — §1.2 Blocking) | – |
| `freeze` | stun; **enemies** also RES −15 | – |
| `cold` | ASPD −30; a 2nd cold while cold ⇒ `freeze` 3 s (unless frozen-immune) | – |
| `sleep` | 无敌且无法行动: inactive, untargetable, **takes no damage** (unless the attacker profile has `hitSleep` or the damage `ignoreSleep`), blocks nothing | – |
| `slow` | moveMul 1 − value (*strongest*) | default 0.5 |
| `sluggish` (停顿) | moveMul 0.2 | – |
| `bind` (束缚) | cannot move | – |
| `fragile` / `artsFragile` / `physFragile` / `elemFragile` | taken ×(1+value) (*strongest*) | 0.3 / 0.3 / 0.3 / 0.2 |
| `fragile` / `artsFragile` / `physFragile` / `elemFragile` | taken ×(1+value) (*strongest*); `elemFragile` = 元素脆弱 ("受到的元素伤害提升"): `elementalTakenMul`, 元素伤害 only — never the gauge | 0.3 / 0.3 / 0.3 / 0.2 |
| `silence` | no skill activation | – |
| `fear` (恐惧) | 无法被阻挡并四散逃跑: enemy cannot attack, is unblockable (released) and does not advance | – |
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed) | stacks, default 1 |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
| `disarm` | no normal attacks | – |
| `stealth` / `reveal` | untargetable unless blocked / cancels stealth | – |
| `stealth` / `reveal` | untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth | – |
| `invulnerable` | ignores damage | – |
| `levitate` (浮空) | stun + unblockable (unblocks enemies); **half duration on units with (current) massLevel > 3** | – |
| `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – |
| `attract` (诱导) | 无法被阻挡并向目标位置移动: unblockable (released); the engine walks it (own speed, grid path re-planned on obstacle changes; flyers straight) to `opts.point` (`[r, c]` or `{x, y}`, default the source's tile, clamped to the rect) and keeps it there; stun/bind/sleep stop it; its route re-plans from where it stands when the status ends. A new application moves the point | point |
| `resist` (抵抗) | the control statuses of `RESIST_STATUSES` (晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速) applied to the unit last ×(1 − value); a resisting unit loses one 麻痹 stack every 5 s. **同名效果不叠加** — `battle.resistOf(u)` = the strongest `status: 'resist'` buff (never a product): several sources (灵知, 流明, 寒檀, enemy talents) never compound. *strongest*; a permanent one that survives death is a `persist` buff with `status: 'resist', data: { value }` | 0.5 (≤ 0.95) |
| `taunt` | taunt level +value | 1 |
@@ -277,14 +337,24 @@ a custom buff with `flags.sleep` also blocks nothing and is untargetable/invulne
**Element gauges** (`unit.elem = {burn, neural, apoptosis, erosion, necrosis}`; capacity `unit.gaugeMax` = 1000, enemy
leaders (rank BOSS / boss units) 2000): deal `{ type:'element', element, amount }` (fires `elementHit` first; the gauge
gain is × `elemTakenMul` × (1 − 损伤抵抗 / 100), 损伤抵抗 = the target's data `epResistance` — PRTS 元素 "受到的元素损伤 =
损伤值 × (1 − 损伤抵抗 × 0.01)"). A full gauge bursts with the official effects, which depend on the side hit
(constants.js `ELEMENT`):
gain is amount × `dmg.mul` × `elemTakenMul` (元素损伤倍率: "受到的元素损伤提高/降低…") × max(5 %, 1 − 损伤抵抗 / 100),
损伤抵抗 = the target's data `epResistance` — PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01),
后续可应用元素损伤倍率", with the 5 % floor of DMG_e (PRTS 游戏数据基础 "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值
改为目标的损伤抵抗即可"); `elementIntake(unit)` in damage.js returns that factor, applied after `elementHit`). 元素脆弱 never
scales it, no gauge decays (EP_RECOVERY_PER_SEC 0), and every enemy in data/enemies.json has 损伤抵抗 0 except 转译基底·α
(10); operators have none.
Enemies deal ATK × their talent's `ep_damage_ratio` per hit (content/enemies.js `ep`). A full gauge bursts with the
official effects, which depend on the side hit (constants.js `ELEMENT`); burst damage is **无来源** (DamageInfo
`sourceless`, PRTS 伤害分类 "无法被追溯伤害来源": no damage-dealt multiplier or penetration of the unit that filled the
gauge, and the `hit` / `damaged` / `fatal` hooks see `source: null` — no attacker-keyed content applies — while the ctx's
`credit`, the stats and the kill keep that unit; target-side reactions with no source condition read `source || credit`:
damage sharing — 圣杯, 盲信之誓, 余音 — and 抵挡 — 星熊 战术装甲, 拉普兰德 日晷, whose "enemy damage" guard is otherwise
the source's side):
| element | operator hit by enemies (ba.dt.*) | enemy hit by operators ("·我方" ba.dt.*2) |
|---|---|---|
| `burn` 灼燃 | 1200 arts + RES −20, 10 s lock | 7000 元素伤害 + RES −20, 10 s lock |
| `neural` 神经 | stun 10 s, then 1000 true (10 s lock) | 3 `palsy`, then 6000 元素伤害, 10 s lock |
| `neural` 神经 | stun 10 s, then 1000 true (10 s lock) | 3 `palsy` (none with 麻痹免疫), then 6000 元素伤害, 10 s lock |
| `apoptosis` 凋亡 | 15 s: 阻回 (`noSp`: no SP gain of any kind, skills.js) + 静默 (no skill activation), −1 SP/s, 100 arts/s | 15 s: 50 % weaken recovering over the burst, 800 元素伤害/s |
| `erosion` 侵蚀 | permanent DEF −100 (stacking `erosionDown`) then 800 phys, 10 s lock | permanent DEF −120 then 5000 元素伤害, 8 s lock |
| `necrosis` (legacy spare gauge) | 12 s: 100 true/s, ATK −20 % | same |
@@ -298,7 +368,14 @@ neighbours (淤困 parasite) cannot bounce it back into a second burst. `element
a unit shows (b.snap `elem`, §9): the fullest one, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经,
侵蚀, 灼燃, 凋亡, then the legacy `necrosis`); during a 爆发冷却 the bursting element with fill 1 and the cooldown's end.
**元素伤害** (element HP damage, e.g. "每秒受到…元素伤害") is the DamageInfo type `'elemental'` (+ optional `element` for the
client colour): no DEF/RES/dodge, × source `dmgDealtMul` × target `dmgTakenMul` × `elemTakenMul`, shields absorb it.
client colour): no DEF/RES/dodge — 元素抗性 instead (the target's data `epDamageResistance`, `max(A × (1 − D/100), 5 %A)`,
PRTS 游戏数据基础 DMG_e; 0 on every enemy in data/enemies.json) —, × source `dmgDealtMul` × target `elementalTakenMul`
(元素脆弱) only — the target's `dmgTakenMul` (脆弱, ba.fragile "受到的物理、法术、真实伤害提升", and the other "受到的伤害±"
effects [ASSUMED: they share that multiplier]) does not scale it —, shields absorb it. An element fill on a target with no
HP left is refused (damage.js `hasHp`: a lethal hit's `damaged` hook runs before `battle.kill`, while the target is still
`alive` at 0 HP — no burst on the corpse). **Element healing** (`battle.reduceElement(unit, amount, el?)`) lowers element
`el`, or every element type, each by `amount` on its own (PRTS 菲莱 备注: 清除元素损伤 = "一次等同于自身最大元素值的全类型元素
损伤治疗").
---
@@ -306,15 +383,19 @@ client colour): no DEF/RES/dodge, × source `dmgDealtMul` × target `dmgTakenMul
`battle.dealDamage(source, target, dmg)` → HP removed. `DamageInfo = { amount, type:'phys'|'arts'|'true'|'elemental'|'element',
element?, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul=1, canDodge (phys/arts), isSkill, isSplash,
isAttack, attackId, ignoreSleep, tags[], cancel }` (`battle.makeDamage(d)` normalises). `attackId` is the same for every
isAttack, attackId, ignoreSleep, sourceless, tags[], cancel }` (`sourceless`: 无来源 damage — the source's stats add
nothing and the hooks get `source: null` plus `credit` = the source, which keeps the stats and the kill; a `loseHp` whose
`from` is 无来源 is 无来源 too) (`battle.makeDamage(d)` normalises). `attackId` is the same for every
damage instance of one normal attack (all targets, splash, chain, projectile impacts; 0 for non-attack damage) — use it
for "本次攻击" procs that must roll once per attack. **Dodge** from several buffs rolls independently: the unit's
`s.dodgePhys` = 1 − Π(1 − pᵢ) (a single source keeps its exact value).
Order: invulnerable / asleep? → **`hit`** (mutate `dmg`, set `dmg.cancel`) → dodge (`rng()`) → mitigation (phys
`max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %A)`, arts `max(A×(1 − R′/100), 5 %A)`, true = A; source ignore
mods are added) → × source `dmgDealtMul` (× phys/artsDealtMul) × target `dmgTakenMul` × type-taken mul × `dmg.mul` →
shields → HP loss (boss units: routed to `sharedBoss.damage(playerId, amount)`) → if HP ≤ 0: **`fatal`**
(`ctx.prevented = true` keeps the unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
`max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %A)`, arts `max(A×(1 − R′/100), 5 %A)`, elemental
`max(A×(1 − 元素抗性/100), 5 %A)`, true = A; source ignore mods are added) → × source `dmgDealtMul` (× phys/artsDealtMul)
× target `dmgTakenMul` (not for elemental) × type-taken mul × `dmg.mul` (a `sourceless` hit skips every source term) →
shields → HP loss
(boss units: routed to `sharedBoss.damage(playerId, amount)`) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the
unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
`battle.heal(source, target, amount, { overheal=false, self, silent })`: no-op on `noHeal` targets (unless self);
× source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; `overheal`
@@ -335,13 +416,13 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `tick` | `{ dt }` | end of every tick |
| `beforeAttack` | `{ attacker, targets, isSkill, profile }` | allies **and** enemies; replace/filter `ctx.targets` |
| `attack` | `{ attacker, targets, isSkill }` | an attack/heal was performed (projectiles may still be in flight) |
| `hit` | `{ source, target, dmg }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain, bursts) |
| `hit` | `{ source, target, dmg, credit }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain; 无来源 `dmg.sourceless` bursts, whose `credit` names the unit credited) |
| `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` |
| `damaged` | `{ source, target, amount, type, dmg }` | after application (`amount` may be 0 when shielded); element fills too |
| `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set |
| `heal` | `{ source, target, amount, opts }` | mutable `amount` |
| `fatal` | `{ unit, source, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items) |
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, 不屈, 复活 items) |
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired', killer }` | unit removed |
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) |
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart |
| `ammoUsed` | `{ unit, left, skill }` | per ammo consumed |
| `spGain` | `{ unit, amount, reason:'time'|'attack'|'hurt'|'init'|…, skill }` | mutable `amount` (time gains fire every tick) |
@@ -377,7 +458,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `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 or on a living unit |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the home tile of an ally piece that has not deployed yet / waits to redeploy — summon tile pickers must skip these (`findTacticalPoint` does) |
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir` |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position) |
| `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a melee unit blocks — always selectable by it (none for a ranged operator on a melee tile, §1.2 Blocking) |
| `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | |
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss) |
| `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` |
@@ -385,7 +467,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); `tile: [r, c]` lands on that tile once (home unchanged — later redeploys use the board tile; refused when off-rect or occupied, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 revive in place |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `displace(enemy, {x, y}, tiles, {force})` | push/pull along passable tiles; heavier (massLevel) enemies move less; unblocks + re-paths |
| `displace(enemy, {x, y}, tiles, {force})` | push/pull along passable tiles (by `motion`: FLY in the rect, else ground-passable); heavier (massLevel) enemies move less; nothing for 失衡免疫 (`noDisplace`: 近地悬浮, 浮空) and leaders; unblocks + re-paths |
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
@@ -457,6 +539,28 @@ has the generic spec. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnT
(token skill of that slot: `variants[owner].bySkill[i]`, module attributes: `.byModule[id]`). Coverage of the selectable
skills: `node tools/kit-coverage.mjs --missing [--tier N] [--strict]`.
Element conventions of the kits (user playtest #5 #3; official term dictionary: 元素损伤 = the gauge, 元素伤害 = HP damage):
- **元素损伤 dealt** = `{ type: 'element', element, amount }` with the official base: "N%攻击力的…损伤" ⇒ `N × ATK` at that
moment, "伤害N%的…损伤" / "相当于法术伤害N%" ⇒ `N ×` the HP damage just dealt (post-mitigation, `damaged` ctx.amount).
No damage buff or 脆弱 scales it (the attached-to damage already carries them).
- **"自身造成的元素损伤提升N%"** (菲莱 / 余 PRP-X while blocking, 塑心 RIT-X vs elite / leader) multiplies EVERY element fill
of the unit — kit talents, a carried 灼燃维式重锤 — in an `elementHit` handler (`ctx.source === unit` ⇒ `ctx.dmg.mul *=`);
a talent-ratio upgrade (盟约·辅助干员 RIT-X `ep_damage_ratio_boss`) touches its talent only (PRTS 备注).
- **"受到的元素损伤±N%"** is also an `elementHit` multiplier (PRTS 元素 "…后续可应用元素损伤倍率提升/降低等效果") — not the
mod `elemTakenMul` (the gauge's generic 元素损伤倍率 in `elementIntake`, applied after every `elementHit` handler, so
after the 损伤屏障; 元素脆弱 is `elementalTakenMul`). On operators (菲莱 神河谕使, 哈洛德 我即军营, 纯烬艾雅法拉 火山灰疗愈,
and the host of 假想敌:淤困, "受到的元素损伤提高至130%") it runs at priority 20, before the 损伤屏障 (菲莱 S1, 纯烬 S2 at −50:
"损伤屏障于伤害计算前、第一天赋后处理"); the enemy-side amplifier 塑心 精神逆构 (×1.2 凋亡 in her range) has no ordering
constraint (priority 0 — enemies carry no 损伤屏障).
- **Element attached to a damage** (a `damaged` hook: 迭代元素, 灼燃维式重锤, 炎佑, 妮芙 S2, 余 S3 火墙): the hook runs before
`battle.kill`, so a killing blow still sees the target `alive` at 0 HP — these riders check HP left (damage.js `hasHp`,
boss: pool HP; tier6 `elementDmg`) and applyElement itself refuses such a target, so nothing bursts on the corpse.
塑心 S2 (安魂的弥撒) is the exception: PRTS "…凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" — a late
`hit` handler (priority −1000, after any cancel), so its 凋亡 lands (and may burst) before the damage; a hit dodged or
absorbed after that still carried it [ASSUMED].
- **盟约·辅助干员 迭代元素** (tier6.js `pithst`): every damage she deals (HP damage > 0; "造成伤害时") attaches `ep_damage_ratio
× ATK` of 神经, then 灼燃, then 凋亡 (`PITHST_ELEMENTS`); the element applied first bursts, so alone she bursts 神经.
### 7.3 SkillSpec schema
```js
@@ -655,7 +759,12 @@ const bombard = (battle, unit, locks) => {
Profile resolution: `PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait tunables (trait text + data
trait.bb, elites include module upgrades) → data fields (dmgType / attackKind / projectile / canHitFly / targetPriority)
→ kit.trait`. Defaults: SNIPER ranged phys arrow · CASTER ranged arts bolt · MEDIC ranged heal · SUPPORT ranged arts ·
TANK/WARRIOR/PIONEER/SPECIAL melee phys (melee cannot hit FLY). Ranged attacks fly as projectiles
TANK/WARRIOR/PIONEER/SPECIAL melee phys (melee cannot hit air units: FLY, 近地悬浮, 浮空 — `Unit.isFlying`). Skill and
talent effects that pick their own victims follow the skill's text and PRTS 备注 (the official per-ability `targetMotion`,
PRTS 选择器, is not in the data): "地面敌人" / "不可对空" ⇒ `!e.isFlying` (e.g. 隐德来希 S2 血镰, 归溟幽灵鲨 拥抱自我,
琳琅诗怀雅 S3 cash-out, 乌尔比安 S1 捕网 [ASSUMED like 雪雉's]); "可对空" or no note ⇒ air units too (no note = [ASSUMED]:
风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts — “耀阳”,
沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体 for good. Ranged attacks fly as projectiles
(`constants.js PROJECTILE_SPEEDS`: arrow 14, bolt 11, bomb/lob 8, orb 10, drone 16, enemy 10 tiles/s; boomerang 15 out,
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃); melee/`none` hits are instant. Kit-settable profile flags beyond the
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
@@ -716,7 +825,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
`down: [[id, respawnAt, respawnTime, state]]` (only when non-empty) = knocked-out operators waiting to redeploy
(`Battle.isDown(u)`: reason `'killed'`, deployed at least once, a finite respawn timer; `state` = constants.js
(`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), deployed at least once, a finite
respawn timer; `state` = constants.js
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / own tile taken); `elem: [[id, element, fill,
cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3).
`fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3).
@@ -797,7 +907,8 @@ bat, blockCnt, cost, respawnTime, spRecovery, tauntLevel, massLevel, hpRecoveryP
skillType, durationType, duration, spType, spCost, initSp, maxCharges, rangeGrid, trigger {rule, grid}, bb, description}`,
`talents [{name, description, bb, bbStr, rangeGrid, tokenKey}]`, `raw`. Enemy: `maxHp, atk, def, res, aspd, bat,
rangeRadius, moveSpeed, massLevel, lpr, motion, applyWay, dmgType, immune, tauntLevel, blockCnt, epResistance,
epDamageResistance, hpRecoveryPerSec, notCountInTotal, tags, abilities, skills, talent, raw`. Tokens use
epDamageResistance, hpRecoveryPerSec, notCountInTotal, hitArea (huge units only, §2; else null), tags, abilities, skills,
talent, raw`. Tokens use
`variants[ownerChessId]` (owner-level stats). `skcom_withdraw` token skills are ignored.
Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected
skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the
+6 -3
View File
@@ -38,9 +38,10 @@ input, textarea, select { -webkit-user-select: text; user-select: text; }
layer: step back over the HUD's safe-area offset so it stays on its unit on a notched phone */
.gm__hud > .uframe { margin-left: calc(-1 * var(--sa-l)); margin-top: calc(-1 * var(--sa-t)); }
/* the shop bar keeps its place at the bottom edge (it may reach into the home-indicator strip): raised by the bottom
inset it covered ~3/4 of the bench pads 2–9 on a notched phone (844×390 with the iPhone insets: 23 % of those tiles
left free, 71 % now — as without insets), and a bench piece is pressed / dropped on by its tile (user playtest #4
item 1). Its right edge stays inside the inset (the notch / rounded corner). */
inset it covered ~3/4 of the bench pads 2–9 on a notched phone (user playtest #4 item 1), and a bench piece is
pressed / dropped on by its tile. The prep camera keeps the bench clear of the bar (ui/fieldHost.js hudBands →
render/projection.js clearHud, user playtest #5 item 9). Its right edge stays inside the inset (the notch / rounded
corner). */
.gm__hud > .shopbar { bottom: calc(.2rem - var(--sa-b)); }
.gm > .spov { left: calc(2.8rem + var(--sa-l)); right: var(--sa-r); }
/* every other screen is laid out inside the safe area (the app backdrop fills the notch strip) */
@@ -84,6 +85,8 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.readybtn { height: max(.56rem, 34px); }
.toolbtn { min-height: max(.5rem, 30px); }
.gm__gear { width: max(.56rem, 34px); height: max(.56rem, 34px); }
/* ⚙ / 📖 / ⛶ keep their desktop proportion (half the button) instead of an 11 px glyph in the grown button */
.gm__corner .gm__gear .icon { width: max(.28rem, 17px); height: max(.28rem, 17px); }
.gtop__exit { min-width: max(.56rem, 34px); min-height: max(.56rem, 34px); }
.gtop__iconbtn.has-sprite, .enemybtn.has-sprite { height: max(.46rem, 26px); width: calc(max(.46rem, 26px) * 35 / 29); }
.gtop__iconbtn.has-sprite.is-wide, .enemybtn.has-sprite.is-wide { width: calc(max(.46rem, 26px) * 61 / 29); }
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
+20 -20
View File
@@ -6,12 +6,12 @@
"stageId": "act2autochess_m01",
"duration": 55,
"frames": 550,
"maxUnits": 30,
"maxUnits": 29,
"file": "normal-m01.json",
"bytes": 433457,
"bytes": 424452,
"result": {
"reason": "timeout",
"killed": 12,
"killed": 13,
"total": 32
}
},
@@ -22,12 +22,12 @@
"stageId": "act2autochess_m02",
"duration": 85,
"frames": 850,
"maxUnits": 26,
"maxUnits": 27,
"file": "normal-m02.json",
"bytes": 618614,
"bytes": 634795,
"result": {
"reason": "timeout",
"killed": 16,
"killed": 15,
"total": 35
}
},
@@ -38,12 +38,12 @@
"stageId": "act2autochess_m03",
"duration": 90,
"frames": 900,
"maxUnits": 33,
"maxUnits": 35,
"file": "normal-m03.json",
"bytes": 791453,
"bytes": 809504,
"result": {
"reason": "timeout",
"killed": 15,
"killed": 12,
"total": 46
}
},
@@ -54,12 +54,12 @@
"stageId": "act2autochess_m04",
"duration": 70,
"frames": 700,
"maxUnits": 35,
"maxUnits": 37,
"file": "normal-m04.json",
"bytes": 597582,
"bytes": 626583,
"result": {
"reason": "timeout",
"killed": 23,
"killed": 21,
"total": 39
}
},
@@ -72,7 +72,7 @@
"frames": 843,
"maxUnits": 30,
"file": "late-m03-r12.json",
"bytes": 638858,
"bytes": 632912,
"result": {
"reason": "cleared",
"killed": 12,
@@ -86,9 +86,9 @@
"stageId": "act2autochess_m01",
"duration": 110,
"frames": 1100,
"maxUnits": 33,
"maxUnits": 34,
"file": "late-m01-r13.json",
"bytes": 904761,
"bytes": 902188,
"result": {
"reason": "timeout",
"killed": 6,
@@ -104,10 +104,10 @@
"frames": 600,
"maxUnits": 36,
"file": "unite-m01.json",
"bytes": 636264,
"bytes": 636396,
"result": {
"reason": "timeout",
"killed": 17,
"killed": 19,
"total": 40
}
},
@@ -118,12 +118,12 @@
"stageId": "act2autochess_m02",
"duration": 75,
"frames": 750,
"maxUnits": 24,
"maxUnits": 25,
"file": "boss-m02.json",
"bytes": 714082,
"bytes": 703728,
"result": {
"reason": "forced",
"killed": 12,
"killed": 13,
"total": 18
}
},
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"cheLine truncated
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2}],["deploy",11],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}],["deploy",12],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defLine truncated
+72 -25
View File
@@ -1,16 +1,22 @@
// render/app.js — battlefield view (DESIGN §9). PixiJS 7 (global PIXI) + pixi-spine (PIXI.spine), loaded on demand
// from /vendor when the page did not include them as classic <script> tags.
//
// const view = await createFieldView(host, { data, assets, audio, settings, padding })
// const view = await createFieldView(host, { data, assets, audio, settings, padding, hud })
// view.setStage(stage) procedural tiles + devices (data/stages.json entry)
// view.setCamera(kind, { rect, side, padding, instant }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
// — the prep cameras (own board / Final Assault half) keep the bench
// and the field clear of `hud(kind, size)` = { top, bottom } px of
// DOM HUD along the top / bottom edge (projection.js clearHud; user
// playtest #5 item 9: the shop bar covered the bench on phones)
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
// operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js)
// element gauges (user playtest #4 items 8 / 9, render/units.js); a
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
// user playtest #5 item 2) goes straight to the held pose, no burst
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
@@ -25,9 +31,10 @@
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body). A dragged piece is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer
// on its body), an item plate centred on it — and drops on the tile under the pointer (render/drag.js); an item
// dropped on a unit's tile equips that unit.
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
// equips that unit.
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -93,7 +100,7 @@ import { BoardScene } from './board3d/scene.js';
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js';
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
import { pickOnTile, pickBattle } from './pick.js';
import { pickOnTile, pickBattle, hitRectAt } from './pick.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -121,7 +128,7 @@ export const DRAG_HOLD_TILES = 0.45;
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
* screen; a flying one by its body alone).
*/
function pickUnitOf(v, walks = false) {
function pickUnitOf(v, walks = false, hitArea = null) {
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
// too: a press there selects it; other dead / dying views are gone
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
@@ -129,7 +136,10 @@ function pickUnitOf(v, walks = false) {
const sc = v.screen;
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
// a huge boss (data `hitArea`): its hit area on the ground and a body box as wide as it are pickable (render/pick.js)
const area = walks && !v.flying ? hitRectAt(v.x, v.y, hitArea) : null;
if (area && body) body.hw = hitArea.w / 2;
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, area, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
}
let pixiPromise = null;
@@ -176,13 +186,15 @@ export function viewKind(kind, opts) {
/** 3D areas without the enemy preview pen block (the own field / both normal halves); see `boardArea`. */
const AREA_NO_PEN = Object.freeze({
normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 12)),
unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 12)),
normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 13)),
unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 13)),
});
/**
* 3D area built for a view kind (viewKind): the enemy preview pen (rows 13–18) only for the 'pen' camera — the prep,
* battle and 联防 cameras show the field alone (user playtest #2 item 6); the boss kinds build the boss field.
* 3D area built for a view kind (viewKind): the enemy preview pen (rows 14–18) only for the 'pen' camera — the prep,
* battle and 联防 cameras show the field alone (user playtest #2 item 6) with its separator rows 6 and 13 (the row-13
* devices blow into the field: act2 m01's blowers, user playtest #5 item 6; the boss field's row-6 devices are drawn
* with the boss field only — board3d/layout.js stageDevices); the boss kinds build the boss field.
*/
export function boardArea(vk) {
if (vk === 'pen') return AREAS.normal;
@@ -200,15 +212,32 @@ export function bandFor(kind) {
return kind === 'pen' ? [6, 18] : [6, 13];
}
/**
* Active field rows [r0, r1] of a view kind for the 2D board (render/tiles.js `setView` field: drawn rows outside it are
* dim scenery without devices): the normal / 联防 / prep fields live between the separator walls (rows 6–13, the
* devices on the row-13 wall included; the row-6 wall's belong to the boss field — tiles.js _stageDevices); the pen
* camera adds the pen (6–18); the boss field 0–6.
*/
export function fieldRows(kind) {
return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13];
}
/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */
export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen';
/**
* 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of
* its own combat, deployed and forced out at once (server/sim/constants.js FORCED_EXIT, user playtest #5 item 2): its
* view goes straight to the held knocked-down pose (UnitView.die(true)) without the death burst; b.snap `down` keeps it.
*/
export const FORCED_EXIT = 'forcedExit';
/**
* Generic death particles for a battle unit's 'die' event: never for stage devices, nor for a summon used up by its
* own effect (user playtest #2 item 4: 香槟炸弹's blast — sim fx `{ consumed: true, id }` before its 'die' — is its end,
* not a knock-out).
* not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT).
*/
export const showsDeathFx = (info, consumed = false) => !consumed && info?.kind !== 'device';
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
export function boardPreference(opt) {
@@ -307,7 +336,8 @@ export function releaseGl(renderer) {
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
* padding?: object|((kind:string, size:{width:number,height:number}) => object) }} [opts]
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}) => {top:number,bottom:number}|null) }} [opts]
*/
export async function createFieldView(host, options = {}) {
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
@@ -392,7 +422,7 @@ export async function createFieldView(host, options = {}) {
let destroyed = false;
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
let stageRec = null;
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) });
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
let camFrom = null, camTo = null, camT0 = 0, camKind = 'prep', camOpts = {}, camMs = CAMERA_MS;
let pendingView = null; // tile band/focus to apply when the camera transition ends
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
@@ -463,7 +493,7 @@ export async function createFieldView(host, options = {}) {
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
const impostors = new ImpostorAtlas(app.renderer);
ctx.impostors = impostors;
tiles.setView(bandFor('prep'), camRect(), fieldFor('prep'));
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
// arrival swaps the atlas in place
const artPromise = loadBoardArt(assets).then((art) => {
@@ -575,6 +605,14 @@ export async function createFieldView(host, options = {}) {
return { top: h * 0.13, bottom: h * 0.12, left: Math.min(170, w * 0.09), right: w * 0.05 };
}
// the HUD bands the prep cameras keep the bench / field clear of (projection.js clearHud; user playtest #5 item 9):
// `opts.hud` = (kind, size) => { top, bottom } | null, or a fixed object; none → the plain official framing
function hudBands(kind, sz) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz) || null; } catch { return null; } }
return opts.hud && typeof opts.hud === 'object' ? opts.hud : null;
}
function camRect() {
const k = viewKind(camKind, camOpts);
if (k === 'pen') return { r0: 14, r1: 18, c0: 7, c1: 13 };
@@ -590,21 +628,21 @@ export async function createFieldView(host, options = {}) {
let rect = o.rect ? normRect(o.rect) : null;
if (k === 'prep') rect = rect ? { ...rect, r0: Math.min(rect.r0, GEO.HAND_ROW) } : null;
// official configBlackBoard framing (render/projection.js presetCamera); the padding only matters for the
// fitted fallback (custom rects, portrait viewports)
// fitted fallback (custom rects, portrait viewports); a prep camera keeps the bench and the field clear of the HUD
const vk = viewKind(kind, o); // (a 'prep' camera on the boss rows = the Final Assault prep)
return presetCamera(k, { width: sz.width, height: sz.height, padding: o.padding || defaultPadding(k, sz) }, {
rect, side: o.side, half: !!o.half, shop: o.shop, fit: !!o.fit, config: stageRec?.config || null,
hud: hudBands(vk, sz),
});
}
// rows drawn per camera kind (module `bandFor`): the normal/unite/prep fields live between the separator walls
// (rows 6–13); the enemy preview pen behind them (rows 14–18) only for the pen camera; the boss field below (0–6)
function fieldFor(kind) { return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13]; }
// rows drawn per camera kind: module `bandFor`; the active field rows: module `fieldRows`
function setCamera(kind, options) {
if (destroyed) return false;
let o = options && typeof options === 'object' ? options : {};
const prevView = viewKind(camKind, camOpts);
const prevBand = bandFor(prevView), prevField = fieldFor(prevView);
const prevBand = bandFor(prevView), prevField = fieldRows(prevView);
const nextKind = typeof kind === 'string' ? kind : 'normal';
// the enemy pen is a detour of the prep camera: remember where it came from; the same kind asked again without
// framing options goes back to exactly that camera (Final Assault half, shop state…)
@@ -622,7 +660,7 @@ export async function createFieldView(host, options = {}) {
else if (vk === 'bossPrep') setPrepField(bossPrepField(camOpts.side === 'R' ? 'R' : 'L'));
const target = targetCamera(camKind, camOpts);
const band = bandFor(vk);
const field = fieldFor(vk);
const field = fieldRows(vk);
const focus = camRect();
board3d?.setFocus(focus);
camMs = Number.isFinite(o.ms) && o.ms >= 0 ? o.ms : (vk === 'pen' || prevView === 'pen' ? PEN_CAMERA_MS : CAMERA_MS);
@@ -1109,7 +1147,9 @@ export async function createFieldView(host, options = {}) {
const units = [];
for (const v of views.values()) {
if (!v.info || v.info.kind === 'device') continue;
const u = pickUnitOf(v, v.info.side !== 'ally');
const enemy = v.info.side !== 'ally';
// a huge boss: its hit area (data/enemies.json `hitArea`, the sim's hit rectangle) is pickable too
const u = pickUnitOf(v, enemy, enemy ? data.enemy(v.info.defId)?.hitArea ?? null : null);
if (u) units.push(u);
}
const hit = pickBattle(units, groundTile(x, y), x, y);
@@ -1323,7 +1363,7 @@ export async function createFieldView(host, options = {}) {
case 'die': {
const v = views.get(e[1]);
const used = consumedIds.delete(e[1]);
if (v && v.alive) { v.die(); if (showsDeathFx(v.info, used)) fx.death(v); }
if (v && v.alive) { v.die(e[2] === FORCED_EXIT); if (showsDeathFx(v.info, used, e[2])) fx.death(v); }
break;
}
case 'leak': {
@@ -1342,6 +1382,13 @@ export async function createFieldView(host, options = {}) {
consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1): its view switches clip set
// (UnitView.setForm); the info keeps it for a view built later
if (e[1] === 'phase' && e[4] && typeof e[4] === 'object' && e[4].id != null) {
const inf = infos.get(e[4].id);
if (inf) inf.form = typeof e[4].kind === 'string' ? e[4].kind : null;
views.get(e[4].id)?.setForm?.(e[4].kind);
}
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
break;
case 'layer': {
+30 -6
View File
@@ -2,9 +2,12 @@
// autochess board at runtime (DESIGN §15): pure JS, no three import (tested in Node; render/board3d/scene.js turns
// the buckets into merged BufferGeometries).
//
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh })
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh, area?: AREAS.normal })
// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
// terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats }
// `devices` = the active stage devices standing on a built tile — the separator row 13 included (act2 m01's blowers at
// the top edge of the normal / 联防 field); the boss field's (rows ≤ 6: its row-6 blowers) only when the boss field is
// built — user playtest #5 item 6 (stageDevices).
//
// World space = render/projection.js: x = col, y = row, z = height (up); tile (r,c) covers [c±½]×[r±½].
// Heights follow render/style.js TILE_H (the Pixi layers stand units on the same tops: tiles.js heightAt).
@@ -45,10 +48,16 @@ const CONTENT_OF = (g) => g !== '#' && g !== 'X';
* ((6,11),(12,20)), leftBoss ((0,0),(6,10)), rightBoss ((0,11),(6,20)); enemy_place_rect ((14,7),(18,13)) + the
* separator in front of it). The official prep / battle view shows only the player's own field and the preview pen
* (the island ends under the bench row); 联防 joins both normal halves; the Final Assault builds the boss field.
* The normal / 联防 field rects also take the separator row 13 above the field (like leftBoss takes row 6 above the
* boss field): the devices standing on it act on the field — act2 m01's 源石流发生装置 #001/#002 (#101/#102) at (13, 5 /
* 9 / 13 / 17) blow DOWN into rows 12–10 — and the official normal rounds show those machines at the field's top edge
* (user playtest #5 item 6: the wind lanes worked but the blowers were missing; the 2D board always drew row 13). The
* row-6 wall under the bench is built in these views too (in leftNormal), without the boss field's devices on it
* (stageDevices).
*/
export const AREAS = Object.freeze({
normal: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
unite: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
normal: Object.freeze([Object.freeze({ r0: 6, r1: 13, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
unite: Object.freeze([Object.freeze({ r0: 6, r1: 13, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
boss: Object.freeze([Object.freeze({ r0: 0, r1: 6, c0: 0, c1: 20 })]),
all: Object.freeze([Object.freeze({ r0: 0, r1: 18, c0: 0, c1: 20 })]),
});
@@ -431,7 +440,7 @@ export function buildBoard(stage, opts = {}) {
}
}
const devices = opts.devices === false ? [] : stageDevices(stage, grid);
const devices = opts.devices === false ? [] : stageDevices(stage, grid, opts.area || null);
for (const d of devices) {
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid);
}
@@ -478,9 +487,23 @@ export function tube(g, a, b, rad, seg = 8, col = [1, 1, 1]) {
const DEVICE_KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
/** Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias }`. */
export function stageDevices(stage, grid) {
/** The boss field's top wall — the row under the normal field's bench (rows ≤ this one are the boss field's). */
export const BOSS_WALL_ROW = 6;
/**
* Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias, rangeTiles }` — every visible
* predefined token of the level is a scene object, a device on the separator row 13 included (act2 m01's 源石流发生装置
* #001 / #002 blow DOWN into the normal field: the official normal rounds show them at its top edge although row 13
* lies outside every configBlackBoard area rect, user playtest #5 item 6). The boss field's devices (rows ≤ 6, its top
* wall under the bench included: act2 m01's #201 / #202 blow DOWN into rows 5–3) are drawn only when `area` builds the
* boss field (a rect below row 6): the official normal cameras never show that wall — it lies under the shop bar in
* prep and off-screen in battle and in the shop-collapsed prep view — while the remake's prep view showed the boss
* round's machines under the bench ("反而在关底吹风机的贴图又出现了"). `area` null = the whole map. The 2D board
* (render/tiles.js _stageDevices) follows the same rule.
*/
export function stageDevices(stage, grid, area = null) {
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const bossShown = !area || area.some((a) => a.r0 < BOSS_WALL_ROW);
const out = [];
for (const d of list) {
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
@@ -488,6 +511,7 @@ export function stageDevices(stage, grid) {
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
if (!active) continue;
const [r, c] = d.pos;
if (r <= BOSS_WALL_ROW && !bossShown) continue;
const t = grid?.[r]?.[c];
if (!t || !t.drawn) continue;
out.push({ kind: d.role, r, c, dir: ['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(d.dir) ? d.dir : 'UP', z0: t.h, key: d.key || null, alias: d.alias || null, rangeTiles: Array.isArray(d.rangeTiles) ? d.rangeTiles : null });
+34 -4
View File
@@ -12,12 +12,36 @@
// pointer's point on the ground, or its drawn body (on screen: the upright line from its feet to its head, in its px
// per tile), whichever is nearer, so a press on a tall enemy's / a boss's body picks it too; a flying enemy (drawn in
// the air, over whatever tile it crosses) by its drawn body alone.
// * a huge boss (巨型单位, data `hitArea` — the sim's hit rectangle, server/sim/body.js; user playtest #5 item 10) is
// also picked by a press anywhere on its drawn body — its hit area on the ground (the 5 × 3 tiles under the model)
// or the upright box from its feet to its head as wide as that area — counted as AREA_PICK tile away, so an ally
// standing on the pressed tile, or a regular enemy near the pointer, still wins.
// * an ally's tile and an enemy both qualify → the nearer one (distance in tiles to the pointer's point), ties → the
// ally, then the front-most.
// (Replaces the drawn-body shapes and the 1-px render probe of v2.2, DESIGN §17.2: the player aims at the grid.)
/** Battle enemies are picked within this many tiles of the pointer's ground point or of their drawn body on screen. */
export const ENEMY_REACH = 0.6;
/**
* A press on a huge boss's hit area (not near its feet / drawn body line) counts as this far — farther than any ally
* on the pressed tile (≤ √0.5) or any enemy within ENEMY_REACH, so those win [ASSUMED].
*/
export const AREA_PICK = 1;
/**
* World rectangle (tiles: x = col, y = row) of a hit area `{ w, h, dx, dy }` for a unit at (x, y) — the same rectangle
* as server/sim/body.js hitRect: `w` along the columns × `h` along the rows, centred `dx` columns right / `dy` rows up.
* @returns {{ x0: number, x1: number, y0: number, y1: number } | null}
*/
export function hitRectAt(x, y, a) {
if (!a || !(a.w > 0) || !(a.h > 0) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
const cx = x + (Number(a.dx) || 0), cy = y + (Number(a.dy) || 0);
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
}
const inArea = (A, g) => !!A && !!g && g.x >= A.x0 && g.x <= A.x1 && g.y >= A.y0 && g.y <= A.y1;
/** Screen point inside a huge body's upright box: `hw` tiles (its px per tile) either side of its x, feet up to head. */
const inBox = (b, px, py) => !!b && b.hw > 0 && b.s > 0 && Math.abs(px - b.x) <= b.hw * b.s
&& py >= Math.min(b.top, b.feet) && py <= Math.max(b.top, b.feet);
const EPS = 1e-9;
const isTile = (t) => !!t && Number.isInteger(t.row) && Number.isInteger(t.col);
@@ -39,10 +63,13 @@ function bodyDist(b, px, py) {
* @typedef {{ row: number, col: number, x?: number, y?: number }} PickGround the tile under the pointer and the pointer's
* point on its top (world tiles: x = col, y = row), or null off the grid
* @typedef {{ tile?: { row: number, col: number } | null, x?: number, y?: number, depth?: number, fly?: boolean,
* body?: { x: number, top: number, feet: number, s: number } | null }} PickUnit
* body?: { x: number, top: number, feet: number, s: number, hw?: number } | null,
* area?: { x0: number, x1: number, y0: number, y1: number } | null }} PickUnit
* `tile`: the tile it stands on (null: it walks — a battle enemy); `x`, `y`: its ground position (world tiles);
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there); `fly`: in the air — its
* ground position does not count; `depth`: draw order (larger = in front)
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there; `hw`: a huge boss's half
* width in tiles); `fly`: in the air — its
* ground position does not count; `area`: a huge boss's hit rectangle on the ground (world tiles, hitRectAt);
* `depth`: draw order (larger = in front)
*/
/**
@@ -87,7 +114,10 @@ export function pickBattle(units, ground, px, py) {
else {
d = bodyDist(u.body, px, py);
if (!u.fly && onGround && Number.isFinite(u.x) && Number.isFinite(u.y)) d = Math.min(d, Math.hypot(u.x - ground.x, u.y - ground.y));
if (!(d <= ENEMY_REACH)) continue;
if (!(d <= ENEMY_REACH)) {
if (u.fly || !(inBox(u.body, px, py) || (onGround && inArea(u.area, ground)))) continue;
d = AREA_PICK;
}
}
if (d < Infinity && better(u, d)) { best = u; bd = d; }
}
+1
View File
@@ -10,6 +10,7 @@
// The view keeps every public coordinate in BOARD space (the server's, g.move targets, canPlace, highlightTiles,
// tileScreen, holdPiece, setPieceDir): only what is drawn and picked goes through this transform. Pure functions.
/** Board row → display (boss-field) row: row − 7 (server/match/board.js BOARD_ROWS_ABOVE_BOSS is +7, the inverse). */
export const BOSS_ROW_SHIFT = -7;
export const MAX_COL = 20;
+65 -1
View File
@@ -25,6 +25,18 @@
// an official shop-view screenshot gave col 5.17, row 2.40, height 11.57 at 30°/40°.) Screens narrower than 16:9
// back the camera off like the client (up to 4:3: Δ = (0, −1.4, −2.8)·t). `presetCamera` returns these official
// cameras; `fitCamera` (frame any rect inside a padded viewport) remains for custom rects and portrait viewports.
//
// HUD clearance of the prep views (user playtest #5 item 9, `clearHud`): the official shop camera puts the bench's
// near edge right on the shop bar's top and the field's back row right under the bond strip at 16:9 (both measured
// flush at 1920×1080). The remake's HUD is sized in rem with a 40 px floor (css/theme.css), so on screens shorter
// than 432 CSS px (phones in landscape) it is taller than the official one relative to the screen and the shop bar
// covered the lower part of bench pads 3–9 (29 % at 844×390, 46 % at the user's Android = 756×366, 51 % at 800×360;
// the Final Assault prep alike). With `opts.hud` (the px bands the HUD covers along the top and bottom edge) the
// official prep camera is kept whenever the band from the bench's near edge to the field's back row fits between them
// — every desktop viewport measured (the Final Assault bench, 1.2 px lower, is nudged up < 3 px at 16:9) — and
// otherwise panned and, only when the band is taller than the space, zoomed out (844×390: ×0.90, 756×366: ×0.84,
// 800×360: ×0.83): a 2D pan / zoom of the image (principal point and focal length), so the perspective and the
// three.js camera stay the official ones.
export const DEG = Math.PI / 180;
@@ -57,15 +69,22 @@ export const OFFICIAL_PARAMS = Object.freeze({
/** Default optics of the fitted (non-official) cameras: the official pitch; dist is a moderate lens. */
export const DEFAULT_OPTICS = Object.freeze({ tilt: OFFICIAL.pitch, dist: 16 });
/** Tile-top heights (tiles) of the prep band's edges (render/style.js TILE_H): bench pads, raised high ground. */
const KEEP_Z_BENCH = 0.16, KEEP_Z_HIGH = 0.42;
/**
* Rects framed by each camera kind (inclusive tile bounds), framing parameters of the fitted fallback, and the
* official configBlackBoard param(s) of the kind (`official`: side → param key; `half` for the ‹ › half views).
* `keep` (prep views): the band `clearHud` keeps clear of the HUD — `near` = world row (y) of the bench's near edge
* (hand row 7 / boss row 0) with the pads' tops at `zNear`, `far` = the field's back edge (row 12 / boss row 5) with
* its raised tops at `zFar`.
*/
export const CAMERA_PRESETS = Object.freeze({
prep: Object.freeze({
rect: Object.freeze({ r0: 7, r1: 12, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_shop_camera_param', R: 'left_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_prepare_camera_param', R: 'left_prepare_camera_param' }),
keep: Object.freeze({ near: 6.5, zNear: KEEP_Z_BENCH, far: 12.5, zFar: KEEP_Z_HIGH }),
}),
normal: Object.freeze({
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 10 }), margin: 0.45, headroom: 1.35, tilt: 30, dist: 13,
@@ -85,6 +104,7 @@ export const CAMERA_PRESETS = Object.freeze({
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
official: Object.freeze({ L: 'left_boss_shop_camera_param', R: 'right_boss_shop_camera_param' }),
officialNoShop: Object.freeze({ L: 'left_boss_prepare_camera_param', R: 'right_boss_prepare_camera_param' }),
keep: Object.freeze({ near: -0.5, zNear: KEEP_Z_BENCH, far: 5.5, zFar: KEEP_Z_HIGH }),
}),
pen: Object.freeze({
rect: Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 }), margin: 0.3, headroom: 1.2, tilt: 30, dist: 11,
@@ -346,6 +366,47 @@ export function officialCamera(param, viewport, opts) {
return new Camera({ tx: cx, ty: cy + height * Math.tan(t), tz: 0, tilt: pitch, dist, scale: focal / dist, cx: W / 2, cy: H / 2 });
}
/** Overlap (px) between the kept band and a HUD band that still counts as clear (float noise). */
const HUD_TOLERANCE = 0.5;
/** Gap (px) an adjusted camera leaves between the kept band and the HUD when there is room (no seam under an edge). */
const HUD_GAP = 1;
/**
* Keep a band of the board clear of the HUD (see the header). `hud` = { top, bottom }: CSS px the HUD covers along
* the viewport's top edge (top bar + bond strip) and bottom edge (the shop bar); `keep` = { near, zNear?, far, zFar? }:
* world rows (y) of the band's near and far edge, at the heights zNear / zFar. Returns `cam` itself when the band
* fits between the HUD bands; else a new camera whose image is panned vertically by the smallest shift that clears
* the HUD (by up to HUD_GAP where there is room) or — only when the band is taller than the space between — zoomed
* out about the viewport's centre so it fills that space less HUD_GAP at both ends. The screen y of a horizontal
* world line is the same at every x (the camera has no roll), so one point per edge decides.
* @param {Camera} cam
* @param {{ top?: number, bottom?: number }|null} hud
* @param {{ near: number, zNear?: number, far: number, zFar?: number }|null} keep
* @param {{ width: number, height: number }} viewport CSS px
* @returns {Camera}
*/
export function clearHud(cam, hud, keep, viewport) {
if (!cam || !hud || typeof hud !== 'object' || !keep) return cam;
const W = Math.max(1, finite(viewport?.width, 1)), H = Math.max(1, finite(viewport?.height, 1));
const top = Math.max(0, finite(hud.top, 0)), bottom = H - Math.max(0, finite(hud.bottom, 0));
if (!(bottom - top > 16)) return cam; // no usable space: keep the official framing
const yNear = cam.project(cam.tx, keep.near, finite(keep.zNear, 0)).y;
const yFar = cam.project(cam.tx, keep.far, finite(keep.zFar, 0)).y;
if (!Number.isFinite(yNear) || !Number.isFinite(yFar) || !(yNear > yFar)) return cam;
if (yNear <= bottom + HUD_TOLERANCE && yFar >= top - HUD_TOLERANCE) return cam;
const spare = bottom - top - (yNear - yFar);
const gap = spare >= 0 ? Math.min(HUD_GAP, spare / 2) : HUD_GAP;
const lo = top + gap, hi = bottom - gap;
const f = spare >= 0 ? 1 : (hi - lo) / (yNear - yFar);
const out = cam.clone();
// image transform x' = xc + f·(x − xc), y' = lo + f·(y − yFar) (zoom) or y' = y + dy (pan, the smallest shift
// that puts [yFar, yNear] inside [lo, hi]): scale the focal length by f and move the principal point accordingly
out.scale = cam.scale * f;
out.cx = W / 2 + f * (cam.cx - W / 2);
out.cy = f < 1 ? lo + f * (cam.cy - yFar) : cam.cy + Math.min(Math.max(0, lo - yFar), hi - yNear);
return out.update();
}
const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0 === b.c0 && a.c1 === b.c1;
/**
@@ -354,6 +415,8 @@ const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0
* viewport is narrower than 4:3 (portrait), `opts.fit` is set, or `opts.rect` is a custom rect — then the rect is
* fitted into the padded viewport (`fitCamera`). `opts.side` 'L'|'R' picks the side's param (right boss half);
* `opts.half` frames only that half of a unite/boss field; `opts.shop === false` uses the shop-collapsed prep camera.
* `opts.hud` = { top, bottom } (CSS px of HUD along the top / bottom edge): the official prep / Final Assault prep
* camera keeps the bench and the field clear of it (`clearHud`, the preset's `keep` band).
*/
export function presetCamera(kind, viewport, options) {
const opts = options && typeof options === 'object' ? options : {};
@@ -374,7 +437,8 @@ export function presetCamera(kind, viewport, options) {
const key = table[side];
const cfg = opts.config && typeof opts.config === 'object' ? opts.config : null;
const param = (cfg && parseCameraParam(cfg[key])) || parseCameraParam(OFFICIAL_PARAMS[key]);
return officialCamera(param, { width: W, height: H }, opts);
const cam = officialCamera(param, { width: W, height: H }, opts);
return preset.keep && opts.hud ? clearHud(cam, opts.hud, preset.keep, { width: W, height: H }) : cam;
}
if (!rect) {
rect = { ...preset.rect };
+28
View File
@@ -12,6 +12,7 @@
// deploy() 'Start' once, then base
// die() die clip once (callers fade out afterwards)
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
@@ -114,6 +115,25 @@ export class SpineActor {
this.roles = clip ? { ...anims, skill: clip } : anims;
}
/**
* Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02
* clips): `roles` override the manifest roles (null = back to them); `change` = a transition clip played once first
* (also while stunned: the pose it ends in is the one a stun then holds).
*/
setForm(roles, change = null) {
const anims = this.entry?.anims || {};
this.roles = roles ? { ...anims, ...roles } : anims;
if (this.dead) return;
if (change && this.has(change)) {
this.stunWanted = this.mode === 'stun';
this.frozen = false;
this.mode = 'change';
this._play(change, false, { mix: 0.08 });
this.changeUntil = this.clock + this.dur(change);
} else if (this.mode === 'base') this._play(this._baseName(), true);
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
}
has(name) { return !!name && this.names.has(name); }
dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); }
@@ -159,6 +179,8 @@ export class SpineActor {
setBase(base) {
if (this.dead) return;
const b = base === 'move' || base === 'stun' ? base : 'idle';
// a mode change clip plays out first; the resting state it lands in is remembered
if (this.mode === 'change') { this.stunWanted = b === 'stun'; if (b !== 'stun') this.base = b; return; }
if (b === 'stun') { this._enterStun(); return; }
if (this.mode === 'stun') this._leaveStun();
if (b === this.base && this.mode !== 'stun') return;
@@ -340,6 +362,12 @@ export class SpineActor {
case 'deploy':
if (this.clock >= this.deployUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
break;
case 'change':
if (this.clock >= this.changeUntil) {
this.mode = 'base';
if (this.stunWanted) { this.stunWanted = false; this._enterStun(); } else this._play(this._baseName(), true);
}
break;
default: break;
}
if (!this.frozen) {
+9 -7
View File
@@ -45,6 +45,8 @@ export const ROW_KEY = Object.freeze({ blocks: 0, devices: 0.01, surface: 0.02 }
export const boxDepthKey = (cam, row) => -cam.depthOf(0, row + 0.45, 0) * 100;
const ROWS = 19, COLS = 21;
/** The boss field's top wall under the normal field's bench (= board3d/layout.js BOSS_WALL_ROW). */
const BOSS_WALL_ROW = 6;
const PLAYABLE_DIST = 2; // margin tiles farther than this from a playable tile are not drawn
const CLIFF_DEPTH = 0.62; // how far the island's edge faces go down to the ground plane (tiles)
/** Brightness of the board meshes (vertex colour above 1 lifts the mid-grey board art a little). */
@@ -362,6 +364,9 @@ export class TileField {
_stageDevices() {
const list = Array.isArray(this.stage?.devices) ? this.stage.devices : [];
// the boss field's devices (rows ≤ 6, its top wall under the bench included) only in the views of the boss field
// (board3d/layout.js stageDevices: the official normal cameras never show that wall; user playtest #5 item 6)
const bossShown = this.field[0] < BOSS_WALL_ROW;
const out = [];
for (const d of list) {
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
@@ -369,14 +374,11 @@ export class TileField {
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
if (!active) continue;
if (d.pos[0] < 0 || d.pos[0] >= ROWS || d.pos[1] < 0 || d.pos[1] >= COLS || d.pos[0] >= 14) continue;
if (d.pos[0] <= BOSS_WALL_ROW && !bossShown) continue;
const gt = this.grid[d.pos[0]] && this.grid[d.pos[0]][d.pos[1]];
if (gt && (!gt.drawn || gt.scenery)) continue;
// a device on the field's edge wall only matters when it acts on this field (blowers blowing into it)
const F = this.field;
if (Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 && (d.pos[0] === F[0] || d.pos[0] === F[1])) {
const into = d.rangeTiles.some(([r, c]) => (r !== d.pos[0] || c !== d.pos[1]) && r > F[0] && r < F[1]);
if (!into) continue;
}
// devices stand on the island (playable tiles + the ring around them, dist ≤ 1 — the 3D board's drawn tiles), not
// on the faded margin; the separator row 13 above the field included (act2 m01's blowers)
if (gt && (!gt.drawn || gt.scenery || gt.dist > 1)) continue;
out.push(d);
}
return out;
+54 -5
View File
@@ -20,6 +20,10 @@
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
// An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit')
// goes down with `die(true)`: straight to the held end of the clip, no fall.
// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1)
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode.
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
@@ -74,6 +78,25 @@ export const DOWN_LOOK = Object.freeze({
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
const EL_RING = 0.3;
/**
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's
* roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
*/
export const FORMS = Object.freeze({
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({
change: 'Change',
roles: Object.freeze({
idle: 'Idle_02', deploy: 'Idle_02', die: 'Die_02',
move: Object.freeze({ begin: null, loop: 'Move_02', end: null }),
attack: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attackAny' }),
skill: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attack', index: 0, idle: null }),
}),
}),
}),
});
/**
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
@@ -168,6 +191,7 @@ export class UnitView {
this.shake = 0;
this.screen = { x: 0, y: 0, s: 1, top: 0 };
this.destroyed = false;
this.form = typeof info.form === 'string' ? info.form : null; // an enemy's mode (setForm, FORMS)
// --- display objects
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
@@ -275,6 +299,9 @@ export class UnitView {
this.spineReady = true;
this.swapT = swap ? 1 : 0;
this.actor.spine.alpha = swap ? 1 : 0;
// a mode the unit is already in (a model built or rebuilt after the change): its clip set, no change clip
const f = this._formSpec();
if (f) this.actor.setForm(f.roles);
// replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end)
if (!this.alive) {
const d = this.actor.die();
@@ -287,6 +314,26 @@ export class UnitView {
}, () => { this._releaseEntry(entry); /* keep the fallback */ });
}
_formSpec() {
return this.form ? FORMS[this.info.spine || this.info.defId]?.[this.form] || null : null;
}
/**
* The unit changed mode (sim fx 'phase' { id, kind }): the mode's clip set (FORMS) after its change clip; a kind with
* no clip set of its own goes back to the manifest clips. Kept for a model built later.
*/
setForm(kind) {
const k = typeof kind === 'string' ? kind : null;
if (k === this.form) return;
const had = !!this._formSpec();
this.form = k;
this.info.form = k;
const f = this._formSpec();
if (!this.actor) return;
if (f) this.actor.setForm(f.roles, f.change || null);
else if (had) this.actor.setForm(null);
}
// ---- HUD -------------------------------------------------------------------------------------------------
_buildHud() {
@@ -495,7 +542,11 @@ export class UnitView {
if (on) this.statuses.add(key); else this.statuses.delete(key);
}
die() {
/**
* The unit died: its Die clip plays, then it fades (unless it stays down, setDown). `instant`: it starts on the held
* end of the clip — a unit that is already down (a field joined mid-battle, an operator entering 联防 knocked out).
*/
die(instant = false) {
if (!this.alive) return;
this.alive = false;
const d = this.actor ? this.actor.die() : 0;
@@ -503,6 +554,7 @@ export class UnitView {
this.dieT = 0;
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
this.dieDur = this.dying;
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
revive() {
@@ -528,10 +580,7 @@ export class UnitView {
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
return;
}
if (this.alive) {
this.die();
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
if (this.alive) this.die(instant);
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
+12 -7
View File
@@ -14,7 +14,8 @@
// it; a refused drop toasts the reason (gameLogic dropFailureReason / canPlace). Tapping an own piece selects it:
// its underframe (ui/underframe.js: 撤退 / 出售 +N / 销毁), its rotated range tiles and its detail card; right-click /
// long-press opens the detail card only. There is no drag-to-sell. The own board is drawn and checked with the
// player's stage overrides (terrain 机变 cards: gameLogic stageOverrides / effectiveStage).
// player's stage overrides (terrain 机变 cards: gameLogic stageOverrides / effectiveStage); in a boss round's prep the
// legality reads the player's half of the boss field (gameLogic deployFieldOf, user playtest #5 item 7).
// Shortcuts: R refresh, F freeze, D level-up, Space ready, Esc closes the topmost popup.
// The UI never mutates match state locally; it re-renders from m.public / m.private / m.field pushes.
// Client-side combat (DESIGN §14, m.public.combatMode 'client'): battles are simulated in this browser by
@@ -74,7 +75,7 @@ import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -227,17 +228,21 @@ function MatchScreen() {
// latest values for event handlers bound once
const live = useRef({});
// the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's
// prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server)
const deployField = deployFieldOf(pub, myId);
const placeCtx = useMemo(() => placementContext({
priv, stage: gd.stage(pub?.stageId), editable,
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect,
}), [priv, pub?.stageId, editable, gd.ready]);
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
// (research 09 §1.2: the right-hand player's board mirrored; gameLogic prepCamera). The DOM fallback view has no
// boss-field prep: it keeps the own board (every coordinate the UI handles is a board coordinate either way).
const prepCam = viewKind === 'fallback' ? prepCamera(null, myId) : prepCamera(pub, myId);
// (research 09 §1.2: the right-hand player's board mirrored; gameLogic prepCamera). The DOM fallback view keeps the
// own board's layout there (every coordinate the UI handles is a board coordinate either way) and draws the tiles of
// that half (ui/fallbackField.js: the legal fence tiles are floor, not the normal field's walls; user playtest #5 item 7).
const prepCam = prepCamera(pub, myId);
const prepCamKey = `${prepCam.kind}:${prepCam.opts.side}`;
const prepCamSeen = useRef(prepCamKey); // the prep camera last requested
const camRef = useRef({ kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } });
+27 -17
View File
@@ -13,13 +13,15 @@
// like the official client and exactly where the render engine puts them (gameLogic penPlacement = render/pen.js
// layoutPen: rows 14–18 × cols 7–13, upper gate rows 17–18, lower gate 14–15, row 16 empty, ≤ 50 models, ≤ 3 per tile);
// tapping one emits pieceClick { enemyKey, preview: true, unit: { side: 'enemy', defId } }. setCamera('bossPrep') (the
// Final Assault prep) keeps the own board here — every coordinate the UI exchanges is a board coordinate anyway.
// Final Assault prep) keeps the own board layout here — every coordinate the UI exchanges is a board coordinate anyway —
// but draws the tiles of the player's half of the boss field (gameLogic fieldTile, the legality the highlights use: act2
// m01's fence tiles are floor there, not the normal field's walls; user playtest #5 item 7).
import { render } from '../../vendor/preact.module.js';
import { html, TierChip } from './components.js';
import { GEO } from '../../../shared/constants.js';
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
import { tileKey, hasFlag, UF, penPlacement, PEN } from './gameLogic.js';
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile } from './gameLogic.js';
const DRAG_PX = 6;
const DMG_TTL = 900;
@@ -37,6 +39,24 @@ function unitArt(m, info) {
return null;
}
/** Look of the stage tile (row, col) on the fallback board (stage coordinates). */
export function fallbackTileClass(stage, row, col) {
const rows = stage?.rows;
const glyph = Array.isArray(rows) && typeof rows[row] === 'string' ? rows[row][col] : 'r';
const t = stage?.tiles?.[glyph];
if (glyph === 'S') return 'gate';
if (glyph === 'E') return 'goal';
if (!t) return 'void';
if (t.tileKey === 'tile_forbidden') return 'void';
if (t.height === 'HIGH') return t.buildable === 'NONE' ? 'void' : 'high';
if (t.special === 'mire' || glyph === 'm') return 'mire';
if (glyph === 'g') return 'smog';
if (glyph === 'd') return 'deep';
if (glyph === 'i') return 'infect';
if (t.buildable === 'NONE') return 'lane';
return 'floor';
}
/**
* Create the DOM fallback view.
* @param {HTMLElement} host
@@ -52,7 +72,7 @@ export function createFallbackView(host, opts = {}) {
host.appendChild(root);
const st = {
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L',
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L', deployField: 'normal',
mode: 'prep', priv: null, editable: false, canPlace: null,
field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(),
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) },
@@ -196,21 +216,10 @@ export function createFallbackView(host, opts = {}) {
}
// ---- tiles ----------------------------------------------------------------------------------------------
// the stage tile a board tile shows: in a boss round's prep the player's half of the boss field (fieldTile)
function tileClass(row, col) {
const rows = st.stage?.rows;
const glyph = Array.isArray(rows) && typeof rows[row] === 'string' ? rows[row][col] : 'r';
const t = st.stage?.tiles?.[glyph];
if (glyph === 'S') return 'gate';
if (glyph === 'E') return 'goal';
if (!t) return 'void';
if (t.tileKey === 'tile_forbidden') return 'void';
if (t.height === 'HIGH') return t.buildable === 'NONE' ? 'void' : 'high';
if (t.special === 'mire' || glyph === 'm') return 'mire';
if (glyph === 'g') return 'smog';
if (glyph === 'd') return 'deep';
if (glyph === 'i') return 'infect';
if (t.buildable === 'NONE') return 'lane';
return 'floor';
const [r, c] = st.mode === 'prep' ? fieldTile(st.deployField, row, col) : [row, col];
return fallbackTileClass(st.stage, r, c);
}
function dropState(target) {
@@ -369,6 +378,7 @@ export function createFallbackView(host, opts = {}) {
if (kind === 'pen') { schedule(); return; } // the board keeps its rect for the way back
if (o && o.rect && Number.isFinite(o.rect.r0)) st.rect = { ...o.rect };
else if (kind === 'prep' || kind === 'bossPrep' || kind === 'normal') st.rect = { ...GEO.NORMAL_RECT };
st.deployField = kind === 'bossPrep' ? (o?.side === 'R' ? 'bossR' : 'bossL') : 'normal';
if (kind === 'bossPrep') st.camera = 'prep';
else if (kind === 'unite') st.rect = { ...GEO.UNITE_RECT };
else if (kind === 'boss') st.rect = { ...GEO.BOSS_RECT };
+42 -1
View File
@@ -37,6 +37,47 @@ export function hudPadding(kind, size) {
return { top: clampH(rem * 1.95), bottom: clampH(rem * 1.0), left: rem * 2.25, right: rem * 1.05 };
}
/**
* HUD geometry (rem) the prep cameras keep clear (they mirror the CSS; test/ui/playtest5-ui.test.js checks the rules):
* the bond strip's bottom edge (css/screens/game.css .gm__bonds top 1.36rem + a .bslot: disc .52rem + name ≈
* 2.15rem measured) and the shop bar's top edge above the viewport's bottom (css/screens/game-shop.css .shopbar
* bottom .2rem + .shopbar__row padding .1rem ×2 + card height 2.24rem, plus its 2 px + 1 px borders). The shop bar
* sits on the viewport's bottom edge even on a notched phone (css/devices.css, DESIGN §18.1).
*/
export const HUD_REM = Object.freeze({ bondStripBottom: 2.16, shopBarTop: 2.64, shopBarBorderPx: 3 });
/**
* CSS px of HUD along the top edge (top bar + bond strip) and the bottom edge (the shop bar) of the viewport during
* prep — the own board ('prep') or the Final Assault half ('bossPrep'); null for every other camera. The prep camera
* keeps the bench / temp rows and the field's back row clear of them (render/projection.js clearHud; user playtest
* #5 item 9: the rem floor of 40 px makes the HUD relatively taller on phones in landscape and the shop bar covered
* the bench). The prep camera is the shop camera whether or not the bar is collapsed, so the band assumes the bar —
* also for an eliminated player's own board (no shop bar: the band only costs size there, while a camera following
* the bar's presence would have to re-frame whenever it appears, e.g. when the private state arrives after the prep
* camera was set). Scouting a teammate's board uses the 'normal' camera: no band. An armed shop card (two-tap buy,
* css/screens/game-shop.css .scard.is-armed) rises 4 px above the bar's top on a phone and covers the bench pads'
* near corners by ≈ 3 px while it stays armed — less than under the unchanged official camera at 1920×1080 (13 px
* above the bar, ≈ 11 px over the pads).
* @param {string} kind
* @param {{ width: number, height: number }} size
* @returns {{ top: number, bottom: number }|null}
*/
export function hudBands(kind, size) {
if (kind !== 'prep' && kind !== 'bossPrep') return null;
let rem = 100;
let safeTop = 0;
try {
rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100;
// the HUD layer starts below the top safe-area inset (css/devices.css .gm__hud)
safeTop = Math.max(0, document.querySelector('.gm__hud')?.getBoundingClientRect().top || 0);
} catch { /* ignore */ }
const h = size?.height || 1080;
return {
top: Math.min(h * 0.4, safeTop + rem * HUD_REM.bondStripBottom),
bottom: Math.min(h * 0.4, rem * HUD_REM.shopBarTop + HUD_REM.shopBarBorderPx),
};
}
function renderPref() {
try {
const q = new URLSearchParams(globalThis.location?.search || '').get('render');
@@ -82,7 +123,7 @@ export function guardView(view, kind) {
*/
export async function mountFieldView(host) {
const pref = renderPref();
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding };
const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands };
if (pref !== 'fallback') {
try {
// the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect)
+25 -3
View File
@@ -139,9 +139,31 @@ export function PieceThumb({ piece, size = 'md', class: cls, badge }) {
golden=${!!piece.golden} tier=${piece.tier} size=${size} class=${cls} badge=${badge} />`;
}
/**
* A regular gear centred in the 24×24 box (even-odd path): `teeth` trapezoid teeth (width tipW at the tip circle
* rTip, rootW at the root circle rRoot; the first one straight up), arcs along both circles, and a round hole. The
* settings button's gear (user playtest #5 item 8): the hand-written path it replaces had teeth of different sizes
* and spacing on a rim that was not round (it bulged at 9 o'clock and was cut flat elsewhere) — a deformed icon.
* @param {{ teeth?: number, rTip?: number, rRoot?: number, tipW?: number, rootW?: number, rHole?: number }} [o]
*/
export function gearPath({ teeth = 8, rTip = 10, rRoot = 7.4, tipW = 3.2, rootW = 4.4, rHole = 3.3 } = {}) {
const c = 12;
const f = (v) => String(Math.round(v * 100) / 100 + 0);
const pt = (a, r) => `${f(c + r * Math.cos(a))} ${f(c + r * Math.sin(a))}`;
const at = Math.asin(tipW / 2 / rTip), ar = Math.asin(rootW / 2 / rRoot), step = (2 * Math.PI) / teeth;
let d = `M${pt(-Math.PI / 2 - ar, rRoot)}`;
for (let i = 0; i < teeth; i++) {
const t = -Math.PI / 2 + i * step;
d += `L${pt(t - at, rTip)}A${f(rTip)} ${f(rTip)} 0 0 1 ${pt(t + at, rTip)}L${pt(t + ar, rRoot)}`
+ `A${f(rRoot)} ${f(rRoot)} 0 0 1 ${pt(t + step - ar, rRoot)}`;
}
return `${d}ZM${f(c)} ${f(c - rHole)}A${f(rHole)} ${f(rHole)} 0 1 0 ${f(c)} ${f(c + rHole)}`
+ `A${f(rHole)} ${f(rHole)} 0 1 0 ${f(c)} ${f(c - rHole)}Z`;
}
/** Extra 24×24 glyphs used by the in-match HUD (original shapes). */
const GLYPHS = {
gear: 'M10.3 2h3.4l.5 2.6c.6.2 1.2.5 1.7.9l2.5-.9 1.7 2.9-2 1.8c.1.6.1 1.2 0 1.8l2 1.8-1.7 2.9-2.5-.9c-.5.4-1.1.7-1.7.9l-.5 2.6h-3.4l-.5-2.6c-.6-.2-1.2-.5-1.7-.9l-2.5.9L2 14.9l2-1.8a6 6 0 0 1 0-1.8L2 9.5l1.7-2.9 2.5.9c.5-.4 1.1-.7 1.7-.9zM12 8.8a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4z',
export const GLYPHS = Object.freeze({
gear: gearPath(),
eye: 'M12 5c5 0 9 4.5 10 7-1 2.5-5 7-10 7S3 14.5 2 12c1-2.5 5-7 10-7zm0 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
emote: 'M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2zM8.5 8.5v3h2v-3zm5 0v3h2v-3zM7.8 14.2a5.5 5.5 0 0 0 8.4 0l-1.5-1.3a3.5 3.5 0 0 1-5.4 0z',
dp: 'M13 2 4 13.5h6.2L9 22l11-12.5h-6.4z',
@@ -152,7 +174,7 @@ const GLYPHS = {
back: 'M10 5 3 12l7 7 1.4-1.4L6.8 13H21v-2H6.8l4.6-4.6z',
flag: 'M5 2h2v20H5zm3 1h11l-2.5 4.5L19 12H8z',
target: 'M11 2h2v3.1A7 7 0 0 1 18.9 11H22v2h-3.1A7 7 0 0 1 13 18.9V22h-2v-3.1A7 7 0 0 1 5.1 13H2v-2h3.1A7 7 0 0 1 11 5.1zm1 5a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm0 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4z',
};
});
/** Extra HUD glyph. */
export function GIcon({ name, class: cls, title }) {
+62 -14
View File
@@ -5,7 +5,10 @@
//
// Board = own normal field (GEO.FIELD rows 9–12, cols 2–10). Melee chess stand on `melee` deploy tiles
// (LOW, buildable ALL/MELEE); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
// derived from the tile legend when missing). Tokens follow their own `position`.
// derived from the tile legend when missing). Tokens follow their own `position`. In the prep of a boss round
// (最终攻势 / 隐秘核心) the tiles are the player's half of the boss field (`deployFieldOf`: 'bossL' / mirrored
// 'bossR', board (r, c) = stage tile (r − 7, c) / (r − 7, 20 − c)) like the server's deploy map (server/match/
// board.js field; user playtest #5 item 7) — board coordinates stay the same.
// Hand = 10 slots (index = col). Temp slots are server-filled only (no move target).
// Dropping onto an occupied tile/slot swaps (both pieces must be legal at their new spots);
// an EQUIP item dropped onto a chess piece equips it (tokens can't carry items); an Arts (MAGIC) item
@@ -18,6 +21,7 @@ import { GEO, PHASE, UF } from '../../../shared/constants.js';
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
import { layoutPen } from '../render/pen.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js';
import { bossLevelSeconds } from './matchStatus.js';
// ---- small helpers -------------------------------------------------------------------------------
@@ -83,6 +87,32 @@ export function prepCamera(pub, myId) {
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
}
/**
* The field the own pieces are deployed on (server/match/Match.js deployFieldOf): 'bossL' / 'bossR' in the prep of a
* boss round (the same pairing as `prepCamera`), else 'normal'. Placement legality (`placementContext` → `deployMap`
* with `field`) reads that field's tiles.
* @returns {'normal'|'bossL'|'bossR'}
*/
export function deployFieldOf(pub, myId) {
const cam = prepCamera(pub, myId);
return cam.kind === 'bossPrep' ? (cam.opts.side === 'R' ? 'bossR' : 'bossL') : 'normal';
}
/**
* The stage tile [row, col] of board tile (r, c) on deploy field `field` (server/match/board.js fieldTile): the boss
* prep's display transform (render/prepfield.js bossPrepField: row − 7, the right half mirrored col c → 20 − c).
*/
export function fieldTile(field, r, c) {
if (field !== 'bossL' && field !== 'bossR') return [r, c];
return [r + BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c];
}
/** The board tile of stage tile (r, c) on deploy field `field` (server/match/board.js boardTileOf). */
export function boardTileOf(field, r, c) {
if (field !== 'bossL' && field !== 'bossR') return [r, c];
return [r - BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c];
}
/** Banner shown when a phase starts: { title, sub?, tone } or null. */
export function phaseBanner(phase, pub) {
const r = int(pub?.round, 0);
@@ -515,13 +545,22 @@ export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
/**
* Deployable tiles of the own normal field for a stage record.
* Deployable tiles of the own board for a stage record, in board coordinates: the normal field (default), or — `field`
* 'bossL' / 'bossR' — the player's half of the boss field (always derived from the legend + active devices, like the
* server's deploy map on that field).
* @param {any} stage stages.json record
* @param {'normal'|'bossL'|'bossR'} [field]
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string> }} [overrides] boss field
* only (the normal field takes them folded into the stage: `effectiveStage`)
* @returns {{ melee: Set<string>, ranged: Set<string> }} ranged = melee ∪ rangedOnly
*/
export function deploySets(stage) {
export function deploySets(stage, field = 'normal', overrides = {}) {
const melee = new Set();
const ranged = new Set();
if (field === 'bossL' || field === 'bossR') {
for (const [k, cls] of deployMap(stage, { ...(isObj(overrides) ? overrides : {}), field })) { ranged.add(k); if (cls === 'melee') melee.add(k); }
return { melee, ranged };
}
const inField = (r, c) => r >= GEO.FIELD.r0 && r <= GEO.FIELD.r1 && c >= GEO.FIELD.c0 && c <= GEO.FIELD.c1;
const dt = stage?.deployTiles?.normal;
if (isObj(dt) && Array.isArray(dt.melee)) {
@@ -585,21 +624,24 @@ export function stageOverrides(priv, getEffect = () => null) {
/**
* Deploy classes of the own board — mirror of server/match/board.js buildDeployMap (legend + active devices +
* overrides): 'melee' (melee and ranged) or 'ranged' (ranged only) per 'r,c'.
* overrides, on deploy field `field`): 'melee' (melee and ranged) or 'ranged' (ranged only) per board 'r,c'.
* @param {any} stage
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string>, field?: 'normal'|'bossL'|'bossR' }} [o]
* @returns {Map<string, 'melee'|'ranged'>}
*/
export function deployMap(stage, { deviceOverrides = {}, tileOverrides = {} } = {}) {
export function deployMap(stage, { deviceOverrides = {}, tileOverrides = {}, field = 'normal' } = {}) {
const F = GEO.FIELD;
const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r >= F.r0 && r <= F.r1 && c >= F.c0 && c <= F.c1;
const map = new Map();
const rows = Array.isArray(stage?.rows) ? stage.rows : null;
const legend = isObj(stage?.tiles) ? stage.tiles : {};
for (let r = F.r0; r <= F.r1; r++) {
const line = rows && typeof rows[r] === 'string' ? rows[r] : null;
for (let c = F.c0; c <= F.c1; c++) {
const [sr, sc] = fieldTile(field, r, c);
const line = rows && typeof rows[sr] === 'string' ? rows[sr] : null;
let cls = null;
if (line) {
const g = line[c];
const g = line[sc];
const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null;
if (t) {
const b = t.buildable;
@@ -612,7 +654,7 @@ export function deployMap(stage, { deviceOverrides = {}, tileOverrides = {} } =
}
for (const d of Array.isArray(stage?.devices) ? stage.devices : []) {
if (!isObj(d) || !Array.isArray(d.pos)) continue;
const [r, c] = d.pos;
const [r, c] = boardTileOf(field, d.pos[0], d.pos[1]);
if (!inField(r, c)) continue;
let active;
if (d.alias != null && isObj(deviceOverrides) && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias];
@@ -676,11 +718,12 @@ export function indexPieces(priv) {
/**
* Build the placement context for `canPlace`. The deploy tiles include the player's stage overrides (terrain 机变
* cards, `stageOverrides`), like the server's per-player deploy map.
* @param {{ priv:any, stage:any, editable:boolean, getChess?:(id:string)=>any, getToken?:(id:string)=>any, getItem?:(id:string)=>any,
* getEffect?:(id:string)=>any }} o
* cards, `stageOverrides`), like the server's per-player deploy map, on the field the pieces are deployed on
* (`field` = `deployFieldOf(pub, myId)`: the own board, or the player's half of the boss field in a boss round).
* @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any,
* getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o
*/
export function placementContext({ priv, stage, editable, getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) {
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) {
const pieces = indexPieces(priv);
const boardAt = new Map();
for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e);
@@ -690,8 +733,13 @@ export function placementContext({ priv, stage, editable, getChess = () => null,
let deployed = 0;
for (const e of pieces.values()) if (e.area === 'board' && e.piece.kind === 'chess') deployed += 1;
const count = Number.isInteger(priv?.deployCount) ? priv.deployCount : deployed;
const deploy = deploySets(effectiveStage(stage, stageOverrides(priv, getEffect)));
return { priv, pieces, boardAt, handAt, deploy, cap, count, editable: !!editable, getChess, getToken, getItem };
const ov = stageOverrides(priv, getEffect);
// the own normal board keeps the data's deploy tiles (with the terrain overrides folded in); the boss field of a
// boss round's prep is read from the legend + its devices under the same overrides (server deploy map, field)
const deploy = field === 'bossL' || field === 'bossR'
? deploySets(stage, field, ov)
: deploySets(effectiveStage(stage, ov));
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem };
}
/** Deploy position ('MELEE'|'RANGED') of a chess/token piece, or null for items. */
+39 -9
View File
@@ -496,9 +496,10 @@ export class Match {
ps.eliminate(passedRound);
this.tickerText(`${ps.name}博士中途退出了模拟`);
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
// before the boss fight: pair the players left again (the prep preview shows the new partner / template)
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
this._planBossWaves();
for (const p of this.alivePlayers()) this.markPrivate(p);
for (const p of this.alivePlayers()) p.recompute();
}
this.markPublic();
if (this.teamLp != null) this._syncTeamLp();
@@ -606,8 +607,35 @@ export class Match {
}
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
deployMapFor(ps) {
return buildDeployMap(this.stage, { deviceOverrides: ps.deviceOverrides, tileOverrides: ps.tileOverrides });
/**
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
* half) — or null outside a boss round / for a player without a field.
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
*/
bossGroupOf(ps) {
if (!ps || !Array.isArray(this.bossWaves)) return null;
const g = this.bossWaves.find((x) => Array.isArray(x.players) && x.players.includes(ps.playerId));
return g ? { wave: g.wave, players: g.players, side: g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L' } : null;
}
/**
* The field a player deploys on (server/match/board.js DEPLOY_FIELDS): in a boss round (最终攻势 / 隐秘核心, from its
* ROUND_START on — the pairing exists before PlayerState.startRound's recompute) the player's half of the boss field,
* 'bossL' or 'bossR' (bossGroupOf); otherwise its own normal board. User playtest #5 item 7.
* @returns {'normal'|'bossL'|'bossR'}
*/
deployFieldOf(ps) {
const g = this.bossGroupOf(ps);
return !g ? 'normal' : g.side === 'R' ? 'bossR' : 'bossL';
}
/**
* A fresh deploy map of a player on `field` (default: the field it deploys on now) under its device / tile overrides.
* Pure — no PlayerState cache is touched (the read-only checker invariants.js uses it; PlayerState.deployMap caches).
*/
deployMapFor(ps, field = this.deployFieldOf(ps)) {
return buildDeployMap(this.stage, { deviceOverrides: ps.deviceOverrides, tileOverrides: ps.tileOverrides, field });
}
dispatch(ps, hook, ev = {}, opts = {}) {
@@ -813,10 +841,9 @@ export class Match {
nextEnemiesFor(ps) {
if (!ps.alive) return [];
if (this.bossWaves) {
const g = this.bossWaves.find((x) => x.players.includes(ps.playerId));
const g = this.bossGroupOf(ps);
if (!g) return [];
const side = g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L';
return previewOf([...g.wave.spawns, ...bountySpawns(this.gd, this.round, g.wave, ps.bounties, ps.playerId, { solo: this.isSolo, side })]);
return previewOf([...g.wave.spawns, ...bountySpawns(this.gd, this.round, g.wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: g.side })]);
}
if (!this.wave) return [];
const bounty = bountySpawns(this.gd, this.round, this.wave, ps.bounties, ps.playerId, { solo: this.isSolo });
@@ -1305,14 +1332,17 @@ export class Match {
this.wave = null;
this.bossWaves = null;
const alive = this.alivePlayers();
for (const ps of alive) ps.startRound(r);
// the round's enemies (shared composition, generated now so the prep preview is exact)
// the round's enemies (shared composition, generated now so the prep preview is exact). Planned BEFORE the players'
// round start: its recompute() checks the board on the field the player deploys on this round (deployFieldOf reads
// the boss pairing), so R14 → R15 never re-checks a boss-field board against the normal field (user playtest #5
// item 7). rngWaves is used only here, so the order leaves every random stream unchanged.
const isBoss = r === this.gd.bossRound || r === this.gd.hiddenRound;
if (isBoss) {
this._planBossWaves();
} else {
this.wave = buildNormalWave(this.gd, this.rngWaves, this.factions, r);
}
for (const ps of alive) ps.startRound(r);
for (const ps of alive) this.dispatch(ps, 'onRoundStart', { round: r });
for (const ps of alive) ps.recompute();
this.setDeadline(DELAYS.ROUND_START / 1000, () => this.afterRoundStart(), { silent: this.soloUntimed });
+20 -3
View File
@@ -12,8 +12,10 @@
// usable (move to a free hand slot, place, equip, destroy, sell) through the NEXT prep — nothing is destroyed before
// the player saw it in a prep ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
// * Board: own region rows 9–12 × cols 2–10, legality from the stage legend (board.js); deploy cap 8 (+effects);
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. A terrain change
// (terrain 机变 cards, content overrides) withdraws the pieces left on tiles they may no longer occupy (hand,
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. In a boss round the
// legality reads the player's half of the boss field (Match.deployFieldOf → board.js field 'bossL' / 'bossR',
// user playtest #5 item 7); board coordinates are unchanged. A terrain change (terrain 机变 cards, content
// overrides) or a change of the deploy field withdraws the pieces left on tiles they may no longer occupy (hand,
// overflow temp; summons back onto their stack) — see _evictIllegal.
// * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
@@ -122,6 +124,8 @@ export class PlayerState {
/** last combat result for this player (unite carry state, bounties) */
this.lastResult = null;
this._deployMap = null;
/** the deploy field of `_deployMap` ('normal' | 'bossL' | 'bossR', Match.deployFieldOf) */
this._deployField = undefined;
/** the deploy map changed since the board's legality was last checked (invalidateDeployMap) */
this._legalityStale = false;
/** Match.scheduleBotPrep: the latest bot prep of this seat (older sliced rehearsals drop out) */
@@ -199,8 +203,19 @@ export class PlayerState {
return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id));
}
/**
* Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now
* (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7).
* A change of that field (the boss round begins, a re-pairing) re-checks the board's legality like a terrain change.
*/
deployMap() {
if (!this._deployMap) this._deployMap = this.m.deployMapFor(this);
const field = typeof this.m.deployFieldOf === 'function' ? this.m.deployFieldOf(this) : 'normal';
if (this._deployMap && this._deployField !== undefined && this._deployField !== field) {
this._deployMap = null;
this._legalityStale = true;
}
if (!this._deployMap) this._deployMap = this.m.deployMapFor(this, field);
this._deployField = field;
return this._deployMap;
}
/**
@@ -1280,6 +1295,7 @@ export class PlayerState {
}
recompute() {
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
if (this._legalityStale) this._evictIllegal();
this.bonds = computeBonds(this.gd, this);
this.dirty();
@@ -1292,6 +1308,7 @@ export class PlayerState {
battleInput({ side = 'L', colOffset = 0, carry = null } = {}) {
// a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board
this.deployMap();
if (this._legalityStale) this.recompute();
const units = [];
for (const { r, c, piece } of boardOrder(this.board)) {
+43 -6
View File
@@ -10,12 +10,22 @@
// `tileOverrides { 'r,c': 'melee'|'ranged'|'none' }` force a class. The result equals stages[id].deployTiles.normal
// for the unmodified stage (asserted in test/match/board.test.js).
//
// Deploy field (user playtest #5 item 7): in the prep of a boss round (最终攻势 / 隐秘核心) the player deploys on ITS
// half of the boss field (research 09 §1.2, official ConvertChessPositionInfoToBossMap / player_map_ud_offset 7 /
// player_map_lr_boundary_col 10), so legality reads the tiles and devices THERE: `field` 'bossL' → board (r, c) is
// stage tile (r − 7, c); 'bossR' (the second player of a pair, mirrored) → (r − 7, 20 − c); 'normal' → (r, c). Board
// coordinates stay the same everywhere (the server's board, g.move, the sim input); only the tile read changes. E.g.
// act2 m01's 1×3 fenced tiles (tile_fence_bound, LOW / buildable ALL) at boss (3–5, 8) and (3–5, 12) = board
// (10–12, 8), which are '#' on the normal field. The result equals stages[id].deployTiles.bossLeft / bossRight
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
//
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
// `parseDir` validates an intent's optional direction.
import { GEO } from '../../shared/constants.js';
import { DEFAULT_DIR, isDir } from '../sim/dir.js';
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
export const FIELD = GEO.FIELD; // { r0: 9, r1: 12, c0: 2, c1: 10 }
export const tileKey = (r, c) => `${r},${c}`;
@@ -29,22 +39,49 @@ export const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
const PLATFORM_ROLES = new Set(['platform']);
/** Deploy fields: the own normal board, or the player's half of the boss field (left / mirrored right). */
export const DEPLOY_FIELDS = Object.freeze(['normal', 'bossL', 'bossR']);
/**
* How many rows a board row lies below its boss-field row: board row = boss-field row + 7 (official
* player_map_ud_offset 7; = −sim BOSS_ROW_OFFSET). Note the sign: render/prepfield.js BOSS_ROW_SHIFT is −7 (board →
* display row), the opposite direction.
*/
export const BOARD_ROWS_ABOVE_BOSS = -BOSS_ROW_OFFSET;
/** Mirror column of the right boss half: board col c ↔ field col 20 − c (sim Battle.mapTile). */
export const BOSS_MIRROR_COL = COLS - 1;
const fieldOf = (f) => (DEPLOY_FIELDS.includes(f) ? f : 'normal');
/** The stage tile [row, col] a board tile (r, c) stands on in deploy field `field`. */
export function fieldTile(field, r, c) {
const f = fieldOf(field);
if (f === 'normal') return [r, c];
return [r - BOARD_ROWS_ABOVE_BOSS, f === 'bossR' ? BOSS_MIRROR_COL - c : c];
}
/** Inverse of fieldTile: the board tile of stage tile (r, c) in deploy field `field`. */
export function boardTileOf(field, r, c) {
const f = fieldOf(field);
if (f === 'normal') return [r, c];
return [r + BOARD_ROWS_ABOVE_BOSS, f === 'bossR' ? BOSS_MIRROR_COL - c : c];
}
/**
* @param {object|null} stage data/stages.json entry
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string> }} [opts]
* @returns {Map<string, 'melee'|'ranged'>}
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string>, field?: 'normal'|'bossL'|'bossR' }} [opts]
* @returns {Map<string, 'melee'|'ranged'>} keyed by BOARD tile 'r,c' (rows 9–12, cols 2–10)
*/
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {} } = {}) {
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}, field = 'normal' } = {}) {
/** @type {Map<string, 'melee'|'ranged'>} */
const map = new Map();
const rows = stage && Array.isArray(stage.rows) ? stage.rows : null;
const legend = stage && stage.tiles && typeof stage.tiles === 'object' ? stage.tiles : {};
for (let r = FIELD.r0; r <= FIELD.r1; r++) {
const line = rows && typeof rows[r] === 'string' ? rows[r] : null;
for (let c = FIELD.c0; c <= FIELD.c1; c++) {
const [sr, sc] = fieldTile(field, r, c);
const line = rows && typeof rows[sr] === 'string' ? rows[sr] : null;
let cls = null;
if (line) {
const g = line[c];
const g = line[sc];
const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null;
if (t) {
const b = t.buildable;
@@ -61,7 +98,7 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
const devices = stage && Array.isArray(stage.devices) ? stage.devices : [];
for (const d of devices) {
if (!d || !Array.isArray(d.pos)) continue;
const [r, c] = d.pos;
const [r, c] = boardTileOf(field, d.pos[0], d.pos[1]);
if (!inField(r, c)) continue;
let active;
if (d.alias != null && deviceOverrides && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias];
+20 -18
View File
@@ -16,9 +16,10 @@
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
// composition (chooseLineup: greedy seed + swap hill-climbing).
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
// routes on the stage's device-aware ground paths, flying routes through their checkpoints) and weighted by
// their enemies; an exposure model (tile time × DPS of the covering units, blocker hold time, flyers only for
// anti-air) is maximized greedily — blockers first, then damage dealers by DPS, then healers — over every
// routes on the stage's device-aware ground paths, flying routes through their checkpoints; 近地悬浮 enemies walk
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
// covering units, blocker hold time, flyers only for anti-air) is maximized greedily — blockers first, then damage
// dealers by DPS, then healers — over every
// (legal tile, direction) pair: each unit's range grid is rotated per direction (DESIGN §3; RIGHT is tried first
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
@@ -37,11 +38,12 @@
import { GEO } from '../../shared/constants.js';
import { deriveSeed } from '../sim/rng.js';
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir } from './board.js';
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
import { itemKey } from './gamedata.js';
import { computeBonds } from './bondsMeta.js';
import { withBounties } from './waves.js';
import { HOVER_KEYS } from '../sim/content/enemies.js';
/**
* Drive a step generator (planLayoutSteps, createRehearsalSteps, arrangeSteps, botPrepBeginSteps …) to its end in one
@@ -101,6 +103,8 @@ export function botPickCard(m, ps, cards, available) {
const chessRec = (m, id) => m.gd.chess(id);
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
const isBlocker = (c) => !!c && positionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none';
/** 近地悬浮 enemies walk a ground route but are air units (no block, anti-air only — DESIGN §19). */
const HOVER = new Set(HOVER_KEYS);
const hitsFly = (c) => !!c && !!c.canHitFly && !isHealer(c) && c.attackKind !== 'none';
/** Distinct owned members per bond (board + hand + temp), and per-bond member sets. */
@@ -330,9 +334,7 @@ function traceLine(points) {
return out;
}
/** Boss field → own board: board rows 9–12 are boss rows 2–5 (sim BOSS_ROW_OFFSET −7); the right side is mirrored. */
const BOSS_ROW_SHIFT = 7;
const BOSS_MIRROR_COL = 20;
/** Boss field → own board: board.js boardTileOf (board rows 9–12 are boss rows 2–5; the right side mirrored). */
const BOSS_MID_COL = 10;
/** Dwell (s) the planner assumes on a leader's first own tiles (several leaders fight from their spawn point). */
const BOSS_DWELL = 30;
@@ -340,17 +342,16 @@ const BOSS_DWELL = 30;
const LEADER_WEIGHT = 10;
const LEADER_HP = 40000;
/** The boss-round wave of a player's group and its side ('L' | 'R'), or null. */
/** The boss-round wave of a player's group and its side ('L' | 'R'), or null (Match.bossGroupOf). */
function bossWaveOf(m, ps) {
if (!ps || !Array.isArray(m.bossWaves)) return null;
const g = m.bossWaves.find((x) => Array.isArray(x.players) && x.players.includes(ps.playerId));
if (!g || !g.wave) return null;
return { wave: g.wave, side: g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L' };
const g = typeof m.bossGroupOf === 'function' ? m.bossGroupOf(ps) : null;
return g && g.wave ? { wave: g.wave, side: g.side } : null;
}
/**
* The round's routes over the own board (cached per round): [{ n, fly, tiles: ['r,c'…] (gate → objective, own
* region only, objective tile excluded), tileTime (s per tile), hp }], a tile → [[route, index]] index, and the
* The round's routes over the own board (cached per round): [{ n, fly (air units: flyers, and the 近地悬浮 HOVER_KEYS
* on their ground path), tiles: ['r,c'…] (gate → objective, own region only, objective tile excluded), tileTime (s per
* tile), hp }], a tile → [[route, index]] index, and the
* aggregated ground flow per tile. Built from the wave preview (spawn counts per route, enemy HP / speed with the
* round's multipliers). Boss rounds (pass `ps`): the player's boss-field template mapped onto the own board (rows −7,
* the right player's half mirrored; only the routes that end on the player's half), the leader weighted heavily with
@@ -367,7 +368,7 @@ export function fieldModel(m, ps = null) {
const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {};
const routesOut = [];
const toBoard = boss
? ([r, c]) => [r + BOSS_ROW_SHIFT, boss.side === 'R' ? BOSS_MIRROR_COL - c : c]
? ([r, c]) => boardTileOf(boss.side === 'R' ? 'bossR' : 'bossL', r, c)
: (p) => p;
const pushRoute = (tilesRC, n, fly, hp, speed, dwell = 0) => {
const own = tilesRC.map(toBoard).filter(([r, c]) => inRect(r, c)).map(([r, c]) => tileKey(r, c));
@@ -400,8 +401,9 @@ export function fieldModel(m, ps = null) {
// the leader counts like LEADER_WEIGHT tough enemies: its huge pool makes damage on it worth a lot everywhere
const hp = isLeader ? LEADER_HP : ((e && e.stats && e.stats.maxHp) || 1000) * ((s.mods && s.mods.hpMul) || 1);
const spd = ((e && e.stats && e.stats.moveSpeed) || 1) * ((s.mods && s.mods.speedMul) || 1);
const key = `${s.routeIndex}|${isLeader ? 'boss' : ''}`;
const a = per.get(key) || { ri: s.routeIndex, boss: isLeader, n: 0, hp: 0, spd: 0 };
const air = HOVER.has(s.enemyKey);
const key = `${s.routeIndex}|${isLeader ? 'boss' : ''}|${air ? 'air' : ''}`;
const a = per.get(key) || { ri: s.routeIndex, boss: isLeader, air, n: 0, hp: 0, spd: 0 };
a.n += isLeader ? LEADER_WEIGHT : n; a.hp += hp * n; a.spd += spd * n;
per.set(key, a);
}
@@ -418,7 +420,7 @@ export function fieldModel(m, ps = null) {
} else {
const key = `${rt.start[0]},${rt.start[1]}->${rt.end[0]},${rt.end[1]}`;
const path = Array.isArray(gpaths[key]) ? gpaths[key] : traceLine([rt.start, ...(boss && Array.isArray(rt.checkpoints) ? rt.checkpoints : []), rt.end]);
pushRoute(path, a.n, false, a.hp / cnt, a.spd / cnt, dwell);
pushRoute(path, a.n, a.air, a.hp / cnt, a.spd / cnt, dwell); // a hovering enemy: ground tiles, air unit
}
}
}
+7 -4
View File
@@ -23,8 +23,9 @@
// ctx.triggerGarrisons(uid, eventType) re-runs another piece's garrisons (铃兰, "触发…的获得时效果", 特质相同).
// Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on
// owned chess (ev.source.holder). Dispatch order per player: global → band → bonds → garrisons (board reading order,
// then hand) → equipped items → effects (insertion order). Every call is try/catch-guarded; nested dispatch depth
// is capped (MAX_DEPTH) so content can never loop the server.
// then hand) → equipped items → effects (insertion order); onPrice runs the priced chess's own 特质 first (购买价格为N
// sets the price the discounts and caps of bonds / strategies then act on — user playtest #5). Every call is
// try/catch-guarded; nested dispatch depth is capped (MAX_DEPTH) so content can never loop the server.
import { itemKey } from './gamedata.js';
import { boardOrder, parseKey, tileKey } from './board.js';
@@ -188,6 +189,8 @@ export class EffectDispatcher {
this.depth++;
try {
const reg = this.registry;
// 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on
if (hook === 'onPrice') this._garrisons(ps, hook, ev);
// 1. globals
for (const [key, h] of reg.globals()) this._call(ps, key, h, hook, { kind: 'global', key }, ev);
// 2. band
@@ -202,8 +205,8 @@ export class EffectDispatcher {
const h = reg.get(key);
if (h) this._call(ps, key, h, hook, { kind: 'bond', key, bondId, bond: ps.bonds[bondId] ?? null }, ev);
}
// 4. garrisons
this._garrisons(ps, hook, ev);
// 4. garrisons (onPrice: already run as step 0)
if (hook !== 'onPrice') this._garrisons(ps, hook, ev);
// 5. equipped items (not for the item-specific hooks)
if (hook !== 'onEquip' && hook !== 'onArt' && hook !== 'onDestroy') {
for (const holder of ownedChess(ps)) {
+3 -1
View File
@@ -96,7 +96,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
}
// board
let deployed = 0;
const dmap = ps.deployMap();
// a pure read (Match.deployMapFor: the field the player deploys on now) — PlayerState.deployMap() would update the
// player's cached field / legality flag, and this checker must not change when a later recompute moves pieces
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
for (const [k, p] of ps.board) {
const [r, c] = parseKey(k);
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
+13 -11
View File
@@ -7,12 +7,17 @@
// units "率先迎敌" on the RIGHT-hand field (colOffset +8, where the escaped_multi routes enter), the other keeps the
// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep HP%, SP and a
// running timed skill from the end of their own combat (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState,
// "阵地以其当前状态"); an operator dead at the end of its own combat (HP ratio 0) and its summons stay out of the 联防
// battle [ASSUMED: the sim has no "start undeployed with a running redeploy timer" input, so it does not come back
// mid-联防 either]. Enemies = the union of every leaker's counted leaks (same stats: the SpawnSpec mods travel with
// the leak), routed on the escaped template (`escaped_single` for 1 helper, `escaped_multi` for 2): walkers on its
// `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` / `lazerd` (waves.js buildUniteWave); kill bounties keep
// paying the killer (a helper). No IN_BATTLE layer gains. Time limit = the round's combat limit.
// "阵地以其当前状态"). An operator knocked out at the end of its own combat (alive false) is fielded with
// `carryState: { down: true }`: PRTS "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,
// 上一阶段为退场状态的干员强制退场)" — the sim deploys it with everyone and forces it out at once (constants.js
// FORCED_EXIT), so it lies on its own tile with the redeploy ring and comes back like after any knock-out (user
// playtest #5 item 2: it used to be left out and vanished). Its timer is its full redeploy time [ASSUMED: the
// official 联防 setup carries only hp / tech per operator (research 09 §3 HelpBattleInfo), no timer]. Summons are fielded as the board has
// them (a summon's own end state is not carried: unitsEnd lists operators only). Enemies = the union of every leaker's
// counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single`
// for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` /
// `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains. Time
// limit = the round's combat limit.
// LP: an enemy still alive at the end (leaked in the unite battle, or never spawned before the limit) costs its
// SOURCE player 1 LP; each player's round loss = min(lpCap, survivors attributed to them + leaks that could not
// re-enter) — the same 10 cap as a normal round.
@@ -99,19 +104,16 @@ export function uniteBattleOpts(m, plan, timeLimit) {
const wave = buildUniteWave(m.gd, plan.leaked, plan.helpers.length, timeLimit);
const players = plan.helpers.map((ps, i) => {
const carry = new Map();
const dead = new Set();
const r = m.lastResults.get(ps.playerId);
for (const u of (r && r.unitsEnd) || []) {
if (!u || u.uid == null) continue;
if (!u.alive) { dead.add(u.uid); continue; }
// knocked out at the end of its own combat: 强制退场 right after the deployment (see header)
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp: Number.isFinite(u.sp) ? u.sp : 0, skillActive: !!u.skillActive });
}
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
const input = ps.battleInput({ side: 'L', colOffset, carry });
// "阵地以其当前状态": an operator knocked out in the helper's own combat is not on that position (HP ratio 0) — it
// stays out of the 联防 battle together with its summons instead of redeploying at full HP
if (dead.size) input.units = input.units.filter((u) => !(u.kind === 'token' ? dead.has(u.ownerUid) : dead.has(u.uid)));
const ev = { input, kind: 'unite', round: m.round, spawns: wave.spawns };
m.dispatch(ps, 'onBattleStart', ev);
return ev.input && typeof ev.input === 'object' ? ev.input : input;
+125 -32
View File
@@ -5,8 +5,10 @@
// b.step(); b.finished; b.forceEnd(reason); b.time; b.result(); b.snapshot(); b.drainEvents(); b.on/off(...)
//
// Construction creates every ally unit (undeployed) and installs content (kits + domain modules). The first
// step() (or an explicit start()) deploys everything (top→bottom, left→right; mirrored for the right boss side),
// fires `deploy` (initial) for each unit and then `battleStart`.
// step() (or an explicit start()) deploys everything (operators top→bottom, left→right — mirrored for the right boss
// side —, then the summon pieces the same way; the players of a shared field side by side), fires `deploy` (initial)
// for each unit, forces out the operators that enter already knocked out (`carryState.down`, 联防: constants.js
// FORCED_EXIT — down on their tile, redeploy timer running) and then fires `battleStart`.
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
@@ -18,14 +20,15 @@
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
import { TICK, ROWS, COLS, DP_DEFAULTS, DOWN_STATE, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
import { GEO } from '../../shared/constants.js';
import { createRng } from './rng.js';
import { Grid } from './grid.js';
import { Unit } from './units.js';
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js';
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { absoluteRangeKeys, canTargetEnemy, meleeUnit } from './targeting.js';
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
import { ProjectileSystem } from './projectiles.js';
import { SkillRuntime } from './skills.js';
@@ -319,12 +322,40 @@ export class Battle {
if (this.started) return;
this.started = true;
this._safe(() => this._spawnStageDevices(), 'stageDevices');
for (const ps of this.players) {
const order = ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
b.homeR - a.homeR || (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || a.id - b.id);
for (const u of order) this._safe(() => this._deploy(u, { initial: true }), 'initialDeploy', u);
// PRTS 卫戍协议/帮助: "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物(如果有)" — per player the operators
// top row first, left to right within a row (the mirrored right boss side: right to left in field columns,
// research 01 §4.3 "嘲諷優先鏡射"), then the summon pieces in the same order. [ASSUMED] On a shared field (联防, boss)
// the players' fields deploy at the same time (PRTS: one unit after another with a fixed delay, from the battle
// start), so the i-th operators of all players come in together (in `players` order), then the summons likewise
const seq0 = this._deploySeq;
const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
b.homeR - a.homeR || (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || a.id - b.id));
for (const kind of ['op', 'token']) {
const per = lists.map((l) => l.filter((u) => u.kind === kind));
const n = Math.max(0, ...per.map((l) => l.length));
for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]);
}
// 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the
// initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon)
// — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came
const summons = this.allyUnits.filter((u) => u.kind === 'token' && u.alive && u.deployed && u.deploySeq > seq0).sort((a, b) => a.deploySeq - b.deploySeq);
for (const t of summons) t.aggroSeq = ++this._deploySeq;
// 联防 (PRTS 卫戍协议/帮助 "部署完成后…上一阶段为退场状态的干员强制退场"): an operator knocked out at the end of its
// own combat is withdrawn right after the deployment — down on its tile, its redeploy timer starting now (re-read
// after battleStart below; DESIGN §5.5). Its HP ratio is the end-of-phase one (0, as after kill()) until it
// redeploys at full HP.
for (const u of this.allyUnits) {
if (u.kind === 'op' && u.alive && u.carry && u.carry.down === true) {
this._safe(() => { u.hp = 0; this.retreat(u, { reason: FORCED_EXIT }); }, 'forcedExit', u);
}
}
this.emit('battleStart', {});
// redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart
// handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is
for (const u of this.allyUnits) {
if (u.kind !== 'op' || u.alive || u.removed || u.removeReason !== FORCED_EXIT) continue;
u.respawnAt = u.deathAt + Math.max(0, u.base.respawnTime * u.persist.redeployMul * u.s.redeployMul);
}
}
step() {
@@ -741,6 +772,7 @@ export class Battle {
e.alive = true;
e.deployed = true;
e.blockWeight = def.blockCnt ?? 1;
e.hitArea = def.hitArea ?? null; // 巨型单位 hit rectangle (body.js); null = a point
e.lpr = def.lpr;
e.mods = opts.mods ?? null;
e.tag = opts.tag ?? null;
@@ -804,6 +836,7 @@ export class Battle {
u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0;
u.blocking = [];
u.deploySeq = ++this._deploySeq;
u.aggroSeq = u.deploySeq;
u.deployedAt = this.time;
u.atkCd = 0;
u.lastAttackAt = -Infinity;
@@ -1001,28 +1034,66 @@ export class Battle {
// =============================================================================================================
// blocking
/** Try to block enemy `e` at its current tile. Returns true if it became blocked. */
/**
* Try to block enemy `e` where it stands. Returns true if it is (now) blocked. Official contact rule (constants.js
* BLOCK_RADIUS, PRTS 游戏数据基础 §阻挡半径): the enemy's position within the blocker's block radius of the blocker's
* centre (ground 0.7071 — the tile's circumscribed circle, reaching 0.21 tile into the side neighbours; air 0.8944;
* devices 0.4472), and the blocker has free capacity for the enemy's block weight. Checked every tick for every
* unblocked enemy, moving or not, so an enemy overlapping an operator is taken over as soon as its blocker is gone or
* the operator's capacity frees up (user playtest #5 item 4). Several blockers in contact → the nearest [ASSUMED],
* ties → the first in row-then-column scan order.
*/
_checkBlock(e) {
if (e.blockedBy || e.hidden || !e.alive) return !!e.blockedBy;
const f = e.s.flags;
if (f.unblockable || f.levitate || f.fear) return false;
const r = Math.round(e.y), c = Math.round(e.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return false;
const u = this._occ[r * COLS + c];
if (!u || !u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false;
const r0 = Math.round(e.y), c0 = Math.round(e.x);
const w = e.blockWeight ?? 1;
let u = null, bd = Infinity;
const fly = e.isFlying;
for (let r = r0 - 1; r <= r0 + 1; r++) {
if (r < 0 || r >= ROWS) continue;
for (let c = c0 - 1; c <= c0 + 1; c++) {
// a diagonal neighbour's centre is ≥ √0.5 away: out of reach of the ground (and device) radius
if (c < 0 || c >= COLS || (!fly && r !== r0 && c !== c0)) continue;
const o = this._occ[r * COLS + c];
if (!o || !this._blockerFor(o, e, w)) continue;
const d2 = (e.x - c) * (e.x - c) + (e.y - r) * (e.y - r);
const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly : BLOCK_RADIUS_SQ.ground;
if (d2 < r2 && d2 < bd) { u = o; bd = d2; }
}
}
if (!u) return false;
e.blockedBy = u;
u.blocking.push(e);
e.moving = false;
if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e });
return true;
}
/**
* The enemies ally `u` blocks that it may target with `profile` — always selectable by a melee blocker, inside its
* range or not ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT trigger). A ranged
* operator on a melee tile gets none: it attacks what its range holds (targeting.js meleeUnit, PRTS 索敌的概念
* "阻挡(近战限定)").
*/
blockedTargets(u, profile) {
const out = [];
if (!u || !u.blocking || !u.blocking.length || !meleeUnit(u)) return out;
for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e);
return out;
}
/** Can ally/device `u` block enemy `e` (block weight `w`) right now — everything but the contact distance. */
_blockerFor(u, e, w) {
if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false;
if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false;
if (!e.isFlying && !u.ground) return false;
const cap = u.s.blockCnt;
if (cap <= 0) return false;
let used = 0;
for (const x of u.blocking) used += x.blockWeight ?? 1;
const w = e.blockWeight ?? 1;
if (used + w > cap) return false;
e.blockedBy = u;
u.blocking.push(e);
e.moving = false;
if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e });
return true;
return used + w <= cap;
}
_unblock(e) {
@@ -1182,6 +1253,10 @@ export class Battle {
if (!(duration > 0)) return false;
const immune = target.def && target.def.immune;
if (!opts.force && tpl.immune && immune && immune.has(tpl.immune)) return false;
// 浮空 Buff (PRTS 异常效果: "若单位数据上为飞行单位且不持有缚地异常或是持有浮空异常则Buff取消"; 行动方式 "行动类型(数据)为
// 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) and units already
// levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 缚地 / 浮空强化 in this mode)
if (key === 'levitate' && (target.motion === 'FLY' || target.s.flags.levitate)) return false;
if (this._hooks.beforeStatus) {
const c = this.emit('beforeStatus', { source: opts.source ?? null, target, status: key, duration, value, cancel: false });
if (c.cancel || !target.alive) return false;
@@ -1291,13 +1366,14 @@ export class Battle {
/**
* HP loss that ignores DEF/RES, dodge and shields (流失). May kill. opts: { source, silent, tags, from } — `from` = the
* DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is
* kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into.
* kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss
* derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited).
*/
loseHp(target, amount, { source = null, silent = false, tags = null, from = null } = {}) {
if (!target || !target.alive || !(amount > 0)) return 0;
const t = ['hpLoss'];
for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x);
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null });
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!(from && from.sourceless) });
}
reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); }
@@ -1307,11 +1383,19 @@ export class Battle {
// =============================================================================================================
// queries
/**
* Tile buckets of the living enemies: a regular enemy on the tile of its position, a huge one (body.js) on every tile
* it occupies.
*/
_buildEnemyIndex() {
for (const k of this._ebUsed) this._eb[k].length = 0;
this._ebUsed.length = 0;
for (const e of this.enemies) {
if (!e.alive || e.hidden) continue;
if (e.hitArea) {
for (const k of bodyKeys(e)) { const b = this._eb[k]; if (!b.length) this._ebUsed.push(k); b.push(e); }
continue;
}
const r = Math.round(e.y), c = Math.round(e.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
const k = r * COLS + c;
@@ -1321,14 +1405,14 @@ export class Battle {
}
}
/** Targetable enemies standing on any of `keys` (absolute tile keys). */
/** Targetable enemies whose body is on any of `keys` (absolute tile keys; a huge enemy is listed once). */
enemiesInKeys(keys, attacker, profile) {
const out = [];
if (!keys) return out;
for (let i = 0; i < keys.length; i++) {
const b = this._eb[keys[i]];
if (!b || !b.length) continue;
for (const e of b) if (e.alive && canTargetEnemy(attacker, e, profile)) out.push(e);
for (const e of b) if (e.alive && (!e.hitArea || !out.includes(e)) && canTargetEnemy(attacker, e, profile)) out.push(e);
}
return out;
}
@@ -1364,7 +1448,7 @@ export class Battle {
const list = want === 'enemy' ? this.enemies : this.allyUnits;
for (const x of list) {
if (!x.alive || !x.deployed || x.hidden) continue;
if (set.has(Math.round(x.y) * COLS + Math.round(x.x))) out.push(x);
if (bodyInKeys(x, set)) out.push(x);
}
return out;
}
@@ -1375,11 +1459,17 @@ export class Battle {
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
}
enemiesInRadius(x, y, r) {
/**
* Living enemies whose body (body.js: a huge enemy's hit rectangle, else its position) is within `r` of (x, y).
* `centre` = a 中点判定 radius — splash around a struck / marked target (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3
* 溅射半径"): every enemy counts by its position (判定中心), a huge one too.
*/
enemiesInRadius(x, y, r, centre = false) {
const out = [];
const r2 = r * r + 1e-9;
for (const e of this.enemies) {
if (!e.alive || e.hidden) continue;
if (e.hitArea && !centre) { if (bodyInRadius(e, x, y, r)) out.push(e); continue; }
const dx = e.x - x, dy = e.y - y;
if (dx * dx + dy * dy <= r2) out.push(e);
}
@@ -1558,6 +1648,7 @@ export class Battle {
u.markDirty();
u.hp = u.s.maxHp;
u.deploySeq = ++this._deploySeq;
u.aggroSeq = u.deploySeq;
u.deployedAt = this.time;
u.rangeKeys = [];
this._occ[row * COLS + col] = u;
@@ -1618,9 +1709,11 @@ export class Battle {
/**
* Displace an enemy (hook/push). dir = {x,y} (normalised internally), distance in tiles. Heavier enemies move
* less: effective = distance × clamp(1 − 0.25·(massLevel − force), 0, 1) (current massLevel: 失重 counts).
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") ⇒ no movement. The tiles it may cross
* follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
*/
displace(e, dir, distance, { force = 1 } = {}) {
if (!e || !e.alive || e.side !== 'enemy' || e.isBoss) return 0;
if (!e || !e.alive || e.side !== 'enemy' || e.isBoss || e.s.flags.noDisplace) return 0;
const len = Math.hypot(dir.x, dir.y);
if (!(len > 0)) return 0;
const eff = distance * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - force)));
@@ -1631,7 +1724,7 @@ export class Battle {
while (moved + 1e-9 < eff) {
const nx = e.x + ux * stepLen, ny = e.y + uy * stepLen;
const r = Math.round(ny), c = Math.round(nx);
const ok = e.isFlying ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
if (!ok) break;
e.x = nx; e.y = ny; moved += stepLen;
}
@@ -1863,12 +1956,12 @@ export class Battle {
/**
* A knocked-out operator waiting to redeploy on its own tile (DESIGN §5.5: after its respawn time, when the tile is
* free and DP ≥ cost): killed — not withdrawn, not removed for good — after it was deployed. The client keeps its
* model on the field knocked down with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and
* enemies simply leave.
* free and DP ≥ cost): killed — or entering the battle knocked out (FORCED_EXIT, 联防) — not withdrawn, not removed
* for good, after it was deployed. The client keeps its model on the field knocked down with a redeploy countdown
* (b.snap `down`, render/units.js); summons, devices and enemies simply leave.
*/
isDown(u) {
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && u.removeReason === 'killed'
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && (u.removeReason === 'killed' || u.removeReason === FORCED_EXIT)
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
}
+23 -10
View File
@@ -7,11 +7,17 @@
// of the leg's target (grid.js, research 08 §3.4): from the tile it stands on the enemy walks straight to the centre
// of `next[tile]`, then on to `next[next[tile]]` … (tile centre to tile centre, so the Bresenham line-of-sight check
// of the smoothing guarantees it never clips a wall or crate corner); the plan is re-read when the grid version
// changes (obstacles) or content displaces the enemy (route.pts = null). FLY legs fly straight between checkpoints. Ground enemies entering a
// tile held by an ally with free block capacity get blocked. Blocked enemies fight their blocker (ranged ones may
// pick anyone in range, blocker first). Unblocked ranged enemies attack allies within their radius and pause
// ATTACK_PAUSE seconds after each attack. Reaching the final leg's end = leak. An `attract` (诱导) status suspends the
// route: the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
// changes (obstacles) or content displaces the enemy (route.pts = null). FLY legs fly straight between checkpoints; the
// path always follows `motion` — a hovering (近地悬浮) enemy is an air unit for targeting and blocking (Unit.isFlying)
// but walks the ground. An unblocked enemy touching an ally with free block capacity — within its block radius (0.7071
// ground, 0.8944 air, devices 0.4472; Battle._checkBlock) — is blocked, moving or not, so an enemy overlapping an
// operator is taken over once its blocker is gone. Blocked enemies fight their blocker (ranged ones may pick anyone in
// range, blocker first); a melee blocker may always target the enemies it blocks, in range or not, a ranged operator on
// a melee tile only what its range holds (acquireTargets, targeting.js meleeUnit).
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. An `attract` (诱导) status suspends the route:
// the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
@@ -113,6 +119,10 @@ export function acquireTargets(b, u, prof) {
if (t.length) return t;
}
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a melee unit blocks are always selectable by it — the block
// radius (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range (user playtest #5 item
// 4); a ranged operator on a melee tile gets none (Battle.blockedTargets: "阻挡(近战限定)")
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
if (!cands.length) return cands;
if (prof.allInRange) return cands;
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
@@ -210,11 +220,11 @@ export function resolveHit(b, u, prof, target, info, x, y) {
if (each) each(target, dealtMain, 'main');
x = target.x; y = target.y;
}
// splash
// splash: a 中点判定 radius around the target's position — a huge enemy counts by its 判定中心 (Battle.enemiesInRadius)
if (prof.splashRadius > 0) {
const r = prof.splashRadius;
const sc = prof.splashScale ?? 1;
for (const e of b.enemiesInRadius(x, y, r)) {
for (const e of b.enemiesInRadius(x, y, r, true)) {
if (e === target) continue;
if (prof.groundOnly && e.isFlying) continue;
if (!prof.canHitFly && e.isFlying && !prof.splashHitsFly) continue;
@@ -484,7 +494,7 @@ function moveAttracted(b, e, dt) {
if (!A.pts || A.ver !== b.grid.version) {
const sr = Math.round(e.y), sc = Math.round(e.x);
let pts = null;
if (!e.isFlying) {
if (e.motion !== 'FLY') { // (a hovering 近地悬浮 enemy is an air unit but walks the ground: motion decides the path)
const path = b.grid.waypoints(sr, sc, A.r, A.c) || b.grid.waypoints(sr, sc, A.r, A.c, { ignoreObstacles: true });
if (path) {
pts = [];
@@ -583,18 +593,21 @@ function enemyAttack(b, e) {
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
let targets = [];
if (e.blockedBy) {
const bl = e.blockedBy;
if (radius > 0) {
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => a === bl || canTargetAlly(e, a, true));
if (!targets.includes(bl) && bl.alive) targets.push(bl);
sortAllyTargets(e, targets);
} else if (bl.alive && bl.deployed) targets = [bl];
} else if (radius > 0) {
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => canTargetAlly(e, a, true));
sortAllyTargets(e, targets);
}
if (own && targets.length) targets = targets.filter((a) => own(a));
if (targets.length > 1) sortAllyTargets(e, targets);
if (!targets.length) return;
// 麻痹 (ba.palsy): each stack interrupts one normal attack
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
+110
View File
@@ -0,0 +1,110 @@
// server/sim/body.js — where a unit can be hit: the ONE helper behind every range test that targets enemies (user
// playtest #5 item 10: "大体型的关底boss实际受击判定范围很小…原版是很大的一片区域").
//
// Regular units are a point: in a grid range when the tile containing their position (round(y), round(x)) is a range
// tile, in a radius when their position is (DESIGN §3). Huge units (巨型单位 — enemy data `hitArea`, tools/build-data.mjs
// HIT_AREAS, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0") are hit anywhere on a rectangle
// `w` tiles along the columns × `h` along the rows, centred on their position moved `dx` columns right and `dy` rows
// up:
// * grid ranges / tile rules: the unit occupies every tile its rectangle overlaps (PRTS 作战机制 "巨型BOSS单位的每一个
// 占据的格子都可以让其本身通过格子判定"; an operator's range — 1×1 tile squares — touches the rectangle exactly then):
// a 4.95 × 2.95 area centred on a tile covers that 5 × 3 block;
// * radius rules (auras, AoE around a point or a unit): the distance from the point to the rectangle (0 inside it)
// [ASSUMED: PRTS 作战机制 calls collider tests "the most common"];
// * splash around a struck / marked target is a 中点判定 (PRTS 作战机制: "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"):
// every enemy, a huge one too, counts by its position (Battle.enemiesInRadius(x, y, r, true) — the engine's
// profession splash and aftershocks, and the kits' splash centred on an enemy).
// The unit's own position stays its 判定中心: projectiles fly to it, its own attacks and radii measure from it, and it
// is the centre of splash on it (PRTS 分支特性信息: 投掷物会抵达目标的判定中心…巨型单位的模型中心具有一定偏移). Body size
// plays no part in blocking or movement — every huge unit here is 自缚 + 无法被阻挡 (PRTS 天赋; content/bosses.js
// SELF_BOUND: persistent noMove + unblockable from spawn). Also by position: movement, pathing and terrain on the
// enemy's tile, tile-entry tracking (雪境 tiles), placement heuristics and distance-scaled numbers, and the range keys
// taken from blocked enemies (a huge unit is never blocked).
// Pure functions of (x, y, hitArea); no battle state — also used for the client's picking (render/pick.js mirrors
// `hitRect`).
import { ROWS, COLS } from './constants.js';
/**
* Validated hit area of a data record (`{ w, h, dx, dy }`, tiles) or null for a regular (point) unit.
* @param {any} a
*/
export function normHitArea(a) {
if (!a || typeof a !== 'object') return null;
const w = Number(a.w), h = Number(a.h), dx = Number(a.dx ?? 0), dy = Number(a.dy ?? 0);
if (!(w > 0) || !(h > 0) || !Number.isFinite(w) || !Number.isFinite(h) || !Number.isFinite(dx) || !Number.isFinite(dy)) return null;
return Object.freeze({ w, h, dx, dy });
}
/** The world rectangle `{ x0, x1, y0, y1 }` of a huge unit, or null for a point unit (no `hitArea`). */
export function hitRect(u) {
const a = u && u.hitArea;
if (!a) return null;
const cx = u.x + a.dx, cy = u.y + a.dy;
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
}
/** Tile key of a unit's position (-1 off the grid). */
function posKey(u) {
const r = Math.round(u.y), c = Math.round(u.x);
return r < 0 || r >= ROWS || c < 0 || c >= COLS ? -1 : r * COLS + c;
}
/**
* Tile keys (row × COLS + col) the unit occupies: a point unit the tile of its position ([] off the grid), a huge unit
* every tile its rectangle overlaps (open intervals: a tile only touched along an edge is not overlapped).
*/
export function bodyKeys(u) {
const R = hitRect(u);
if (!R) { const k = posKey(u); return k < 0 ? [] : [k]; }
const out = [];
const r0 = Math.max(0, Math.floor(R.y0 + 0.5 + 1e-9)), r1 = Math.min(ROWS - 1, Math.ceil(R.y1 - 0.5 - 1e-9));
const c0 = Math.max(0, Math.floor(R.x0 + 0.5 + 1e-9)), c1 = Math.min(COLS - 1, Math.ceil(R.x1 - 0.5 - 1e-9));
for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) out.push(r * COLS + c);
return out;
}
/** Does the unit occupy tile (r, c)? */
export function bodyOnTile(u, r, c) {
const R = hitRect(u);
if (!R) return Math.round(u.y) === r && Math.round(u.x) === c;
return c + 0.5 > R.x0 + 1e-9 && c - 0.5 < R.x1 - 1e-9 && r + 0.5 > R.y0 + 1e-9 && r - 0.5 < R.y1 - 1e-9;
}
/** Does the unit occupy any tile of `keys` (tile keys: a Set / Map — anything with `has` — or an array)? */
export function bodyInKeys(u, keys) {
if (!u || !keys) return false;
const has = typeof keys.has === 'function' ? (k) => keys.has(k) : (k) => keys.includes(k);
if (!u.hitArea) { const k = posKey(u); return k >= 0 && has(k); }
for (const k of bodyKeys(u)) if (has(k)) return true;
return false;
}
/** Distance (tiles) from point (x, y) to the unit's body: its position, or its rectangle (0 inside). */
export function bodyDist(u, x, y) {
const R = hitRect(u);
if (!R) return Math.hypot(u.x - x, u.y - y);
const dx = x < R.x0 ? R.x0 - x : x > R.x1 ? x - R.x1 : 0;
const dy = y < R.y0 ? R.y0 - y : y > R.y1 ? y - R.y1 : 0;
return Math.hypot(dx, dy);
}
/** Is the unit's body within `r` tiles of point (x, y)? (the same ≤ r + 1e-9 test as Battle.enemiesInRadius) */
export function bodyInRadius(u, x, y, r) {
return bodyDist(u, x, y) <= r + 1e-9;
}
/**
* Chebyshev reach in tiles from tile (r, c) to the unit's body (0 = it occupies that tile; 1 = one of the 8 tiles
* around it; …): "周围八格"-style square areas centred on a tile.
*/
export function bodyTileReach(u, r, c) {
if (!u.hitArea) return Math.max(Math.abs(Math.round(u.y) - r), Math.abs(Math.round(u.x) - c));
let best = Infinity;
for (const k of bodyKeys(u)) {
const kr = Math.floor(k / COLS), kc = k % COLS;
const d = Math.max(Math.abs(kr - r), Math.abs(kc - c));
if (d < best) best = d;
}
return best;
}
+11 -7
View File
@@ -25,14 +25,14 @@ export const ADD_KEYS = Object.freeze([
/** Multiplicative mod keys (product; ^ stacks). */
export const MUL_KEYS = Object.freeze([
'atkMul', 'defMul', 'hpMul', 'resMul', 'moveMul', 'dmgDealtMul', 'dmgTakenMul', 'physTakenMul', 'artsTakenMul',
'trueTakenMul', 'elemTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
'trueTakenMul', 'elemTakenMul', 'elementalTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
'atkScaleMul', 'physDealtMul', 'artsDealtMul',
]);
/** Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. */
export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace',
]);
/**
@@ -58,7 +58,9 @@ export const STATUS = Object.freeze({
fragile: { mods: (v) => ({ dmgTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
artsFragile: { mods: (v) => ({ artsTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
physFragile: { mods: (v) => ({ physTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
elemFragile: { mods: (v) => ({ elemTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
// 元素脆弱 (ba.elementfragile "受到的元素伤害提升相应比例(同名效果取最高)"): 元素伤害 (the 'elemental' HP damage) only —
// never the element gauge (元素损伤 has its own multiplier, `elemTakenMul`; damage.js)
elemFragile: { mods: (v) => ({ elementalTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
silence: { flags: { silence: true }, immune: 'silence' },
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable, stops advancing along its route)
fear: { flags: { fear: true, unblockable: true }, immune: 'feared' },
@@ -68,10 +70,12 @@ export const STATUS = Object.freeze({
stealth: { flags: { stealth: true } },
reveal: { flags: { reveal: true } },
invulnerable: { flags: { invulnerable: true } },
// 浮空: 无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半
levitate: { flags: { levitate: true, stun: true, unblockable: true }, immune: 'levitate' },
// 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed
palsy: { palsy: true },
// 浮空: 变为空中单位 (Unit.isFlying),无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半; the state holds 不可阻挡 +
// 失衡免疫 (noDisplace: "不会被位移影响", PRTS 异常效果)
levitate: { flags: { levitate: true, stun: true, unblockable: true, noDisplace: true }, immune: 'levitate' },
// 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed; honours
// 麻痹免疫 (enemy_database palsyImmune, e.g. 假想敌:铳 — PRTS 元素: a 神经 burst gives 麻痹 only "若单位不具有麻痹免疫")
palsy: { palsy: true, immune: 'palsy' },
// value = taunt level delta (default +1)
taunt: { mods: (v) => ({ taunt: v ?? 1 }) },
// value = ATK fraction removed (default 0.3)
+19 -1
View File
@@ -15,6 +15,14 @@ export const COLS = GEO.COLS;
export const MOVE_SCALE = 0.5;
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
export const ATTACK_PAUSE = 0.35;
/**
* Block contact (PRTS 游戏数据基础 §阻挡半径; 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked enemy whose
* position lies within the blocker's radius of the blocker's centre touches it — compared on squared distances, as the
* official client does. Ground blocking 0.70709997 (² 0.49999037); air blocking (起飞 / blockFly units against flyers)
* 0.8944 (² 0.79995137); devices override it (阻隔工事 / 障碍物 0.4472). Used by Battle._checkBlock (user playtest #5).
*/
export const BLOCK_RADIUS = Object.freeze({ ground: 0.70709997, fly: 0.8944, device: 0.4472 });
export const BLOCK_RADIUS_SQ = Object.freeze({ ground: 0.49999037, fly: 0.79995137, device: 0.4472 * 0.4472 });
/** Default projectile speed in tiles/s for ranged operators/enemies. */
export const PROJECTILE_SPEED = 12;
/**
@@ -38,7 +46,8 @@ export const ELEMENTS = Object.freeze(['burn', 'neural', 'necrosis', 'apoptosis'
* Element bursts — official term table (gamedata_const termDescriptionDict):
* `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion),
* `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2;
* `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul).
* `elemDamage` is 元素伤害: HP damage through 元素抗性 instead of DEF/RES, × elementalTakenMul = 元素脆弱).
* Burst damage is 无来源 (PRTS 元素): no damage-dealt multiplier or penetration of the unit that filled the gauge.
* `duration` = the burst's 爆发冷却 (PRTS 元素 table "持续时间": 10 s, 凋亡 15 s, 侵蚀 on enemies 8 s — the operators'
* 侵蚀 burst has its 10 s cooldown too): while it runs NO element of the unit fills or recovers, and when it ends every
* gauge of the unit resets (damage.js).
@@ -93,6 +102,15 @@ export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 })
* free (WAIT_TILE). render/units.js mirrors these codes.
*/
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/**
* Removal reason of an operator that enters a battle already knocked out: a 联防 helper's operator down at the end of
* its own combat (PlayerBattleInput `carryState.down`; PRTS 卫戍协议/帮助 §联防阶段 "上一阶段为退场状态的干员强制退场").
* Battle.start deploys it with everyone, then withdraws it at once with HP 0 — down on its own tile like a knocked-out
* operator (Battle.isDown, b.snap `down`), its redeploy timer running from then — without the knock-out hooks (`kill`,
* `death` with reason 'killed'), which fired in its own combat. render/app.js mirrors the string (no fall, no death
* burst).
*/
export const FORCED_EXIT = 'forcedExit';
/** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */
export const MAX_BATTLE_TIME = 3600;
+2 -4
View File
@@ -34,11 +34,10 @@
// say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0.
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { localOrder, localBefore } from '../../../dir.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8,
fxOn, N4, N8, bodyInKeys,
} from '../../support/index.js';
export const ID = Object.freeze({
@@ -216,7 +215,6 @@ function updateAura(battle, st) {
function raidTile(battle, u, e) {
const er = Math.round(e.y), ec = Math.round(e.x);
const ek = er * COLS + ec;
const ranged = u.def?.position === 'RANGED';
const grid = u.rangeGrid || [[0, 0]];
const ext = num(u.s.rangeExtend);
@@ -226,7 +224,7 @@ function raidTile(battle, u, e) {
const r = er + dr, c = ec + dc;
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.canStand(r, c, { ranged })) continue;
const covers = absoluteRangeKeys(grid, r, c, u.dir, ext).includes(ek) ? 0 : 1;
const covers = bodyInKeys(e, absoluteRangeKeys(grid, r, c, u.dir, ext)) ? 0 : 1;
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
// last tie-break: the offset in the unit's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the
// plain tile-key order), so the landing tile turns with its direction
+28 -13
View File
@@ -44,13 +44,15 @@
// RES +45, animates 2; 斥退 at 999 s.
// boss_7 “萨米的意志” 冰凌 (its normal attack) hits a whole column; 自然涌动 stun + arts DoT; <50 %: damage taken
// ×(1−0.6), 2 targets; Doom at 600 s (LP −30). Targets via fairOrder (two players alternate).
// The huge leaders (SELF_BOUND: 胄 ×2, 管 ×2, 昆图斯, 阿利斯泰尔, 萨米的意志 — the 巨型单位 with a data `hitArea`) are
// 自缚 + 无法被阻挡 (PRTS 天赋): a persistent noMove + unblockable buff from spawn, so they never walk their route.
// Every leader (tag boss) ignores 侵蚀 gauge damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
// LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner).
// fx kinds: 'beam' 'shell' 'explode' 'telegraph' 'charge' 'link' 'dash' 'column' 'tide' 'rockfall' 'tentacle' 'equip'
// 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}).
import { MOVE_SCALE } from '../constants.js';
import { canTargetAlly } from '../targeting.js';
import { canTargetAlly, aggroCmp } from '../targeting.js';
import { compileRoute } from '../ai.js';
import { normalizeRoute } from '../simdata.js';
import {
@@ -82,6 +84,17 @@ const ASPD_FLOOR = 20, CHARGE_RADIUS = 0.35;
const LINK_WIDTH = 0.5;
/** 碎铳之簧 bullet bounce falloff / reach [ASSUMED], combo = 十连击; tag of the damage share passed between springs. */
const SPRING_BOUNCE_FALLOFF = 0.85, SPRING_BOUNCE_RANGE = 2, SPRING_COMBO_HITS = 10, SPRING_SHARE_TAG = 'springShare';
/**
* Leaders that are 自缚 and cannot be blocked — the 天赋 line of every 巨型单位 page (PRTS 假想敌:胄 (both copies) "自缚、
* 不可阻挡", 假想敌:管 (+ 隐秘核心) / 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志” "自缚 … 无法被阻挡"). 自缚 (PRTS
* 异常效果 UNMOVABLE_PRIVATE) = 无法移动, like 束缚 but never recognised as 束缚: flag noMove, not bind. They stand where
* they spawn whatever their route says (the official routes walk to a goal; only the talent holds them), so their
* hit area (body.js) stays on the pipe block. These are exactly the enemies with a data `hitArea`.
*/
export const SELF_BOUND = Object.freeze([
'enemy_9013_acstmk', 'enemy_9013_acstmk_2', 'enemy_9021_acduml', 'enemy_9021_acduml_2', 'enemy_1521_dslily',
'enemy_9032_aclionk', 'enemy_9033_acdeer',
]);
/** 阿利斯泰尔: enemy SP per second [ASSUMED] and 莫非王土 delay after each 王权号令 [ASSUMED]. */
const ENEMY_SP_PER_SEC = 1, ANIMATE_DELAY = 2;
const EQUIP_KEYS = Object.freeze(['enemy_10028_vtswd', 'enemy_10029_vtshld', 'enemy_10030_vtwand']);
@@ -172,7 +185,7 @@ function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at
export function install(battle) {
ensureInstalled(battle);
battle.on('enemySpawn', ({ enemy }) => onSpawn(battle, enemy), { priority: 90 });
// "最终攻势中,敌方领袖不会受到侵蚀损伤" (research 06 §10.1): leaders ignore erosion gauge damage
// "最终攻势中,敌方领袖不会受到侵蚀损伤" (PRTS 卫戍协议:盟约/PRTS盟约记录 规则; research 06 §10.1): leaders ignore erosion gauge damage
battle.on('elementHit', (c) => { if (c.target && c.target.isBoss && c.dmg.element === 'erosion') c.dmg.cancel = true; }, { priority: 100 });
}
export function registerMeta() {}
@@ -180,6 +193,7 @@ export function registerMeta() {}
function onSpawn(b, e) {
const kit = BOSS_KITS[e.defId];
if (typeof kit !== 'function') return;
if (SELF_BOUND.includes(e.defId)) b.addBuff(e, { key: 'boss:selfBound', persist: true, flags: { noMove: true, unblockable: true } });
const tpl = templateOf(b);
const ab = abOf(b, e);
let list = [];
@@ -302,7 +316,7 @@ function kitHelm(ab, e, b, tpl) {
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
fire(b2, e2) { // 【灭顶之灾】
const n = P.low ? 2 : 1;
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || q.deploySeq - p.deploySeq).slice(0, n);
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q)).slice(0, n);
for (const t of ts) fireShell(b2, e2, t);
},
},
@@ -398,7 +412,7 @@ function kitBlade(ab, e, b, tpl) {
s && {
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0,
fire(b2, e2) { // fly at the lowest-ATK operator
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || p.deploySeq - q.deploySeq)[0];
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
if (!t) return;
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
hover(b2, false);
@@ -425,7 +439,7 @@ function kitGun(ab, e, b) {
const list = [
{
spawn(b2, e2) { b2.addBuff(e2, { key: 'boss:unblockable', persist: true, flags: { unblockable: true } }); setAspd(b2); },
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [l.sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq)[0]]; }, // 优先攻击防御力最高的单位
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [l.sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q))[0]]; }, // 优先攻击防御力最高的单位
attack(c, b2) {
const t = c.targets[0];
if (t === P.last) P.n = Math.min(maxN, P.n + 1); else { P.last = t; P.n = 0; }
@@ -451,7 +465,7 @@ function kitGun(ab, e, b) {
s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => groundTargets(b2).length > 0 && !P.charge,
fire(b2, e2) { // 【最终之罚】 charge at the highest-DEF ground unit
const t = groundTargets(b2).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq)[0];
const t = groundTargets(b2).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q))[0];
if (!t) return;
const dur = T(ab, '3.duration') ?? 0;
P.charge = { target: { x: t.x, y: t.y }, until: b2.time + dur, hit: new Set() };
@@ -531,13 +545,14 @@ function kitSpring(ab, e) {
},
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
taken(c, b, e2) {
if (!c.source || c.source.side !== 'ally' || !(c.amount > 0)) return;
const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
if (!src || src.side !== 'ally' || !(c.amount > 0)) return;
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
// 【盲信之誓】 受到伤害时以一定比例传递给假想敌:铳 (and, split, to the other springs — 隐秘核心 text)
const g = gun(b);
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg });
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg });
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: c.source, from: c.dmg, tags: [SPRING_SHARE_TAG] });
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG] });
},
tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
@@ -567,7 +582,7 @@ function kitSpring(ab, e) {
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k));
const prev = t;
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || q.deploySeq - p.deploySeq)[0];
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || aggroCmp(p, q))[0];
}
} else { // 十连击
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
@@ -614,7 +629,7 @@ function kitEcho(ab, e) {
return [
{
spawn(b, e2) { setHits(e2, e2.def.maxHp); hitCount(b, e2, true); e2.blockWeight = ECHO_BLOCK_WEIGHT; setEchoForm(b, e2, 'dark'); },
taken(c, b, e2) { if (c.source && c.source.side === 'ally') echoHit(b, e2); }, // 受到伤害时以自身为中心造成一次范围伤害
taken(c, b, e2) { const s = c.source || c.credit; if (s && s.side === 'ally') echoHit(b, e2); }, // 受到伤害时以自身为中心造成一次范围伤害 (a 无来源 burst too)
},
s && {
cd: s.cd, icd: s.icd,
@@ -718,7 +733,7 @@ function kitQuintus(ab, e, b, tpl) {
const list = [{
spawn(b2, e2) { P.t0 = b2.time; P.stageAt = b2.time; e2.profile.maxTargets = 2; },
before(c, b2, e2) { // 同时攻击防御最高的两个单位
const l = targetsNear(b2, e2, e2.base.rangeRadius || 99).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq);
const l = targetsNear(b2, e2, e2.base.rangeRadius || 99).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q));
if (l.length) c.targets = l.slice(0, 2);
},
dealt(c, b2, e2) { elem(b2, e2, c.target, 'neural', e2.s.atk * atkRatio); },
@@ -746,7 +761,7 @@ const QUINTUS = {
},
Rockfall(b, e, s, P) { // 【崩坍】 delayed strikes on the N highest-DEF units
const n = ROCKFALL_TARGETS[Math.min(P.stage, ROCKFALL_TARGETS.length - 1)];
const ts = allTargets(b, e).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq).slice(0, n);
const ts = allTargets(b, e).sort((p, q) => q.s.def - p.s.def || aggroCmp(p, q)).slice(0, n);
for (const t of ts) {
b.fx('rockfall', { x: t.x, y: t.y, id: t.id, dur: ROCKFALL_DELAY });
b.after(ROCKFALL_DELAY, () => { if (e.alive && t.alive) hurt(b, e, t, e.s.atk * (s.bb.atk_scale ?? 0), 'phys'); }, { owner: e });
+7 -2
View File
@@ -42,7 +42,9 @@
// global_special_choice_all_activated always on
// …prep_finish_bench_at_least / _at_most on when params.prepOk (fallback: contentInfo.handUnits)
// global_special_choice_prep_finish_same_row_at_least 征召: on when, at battle start, some row holds ≥ count of
// the player's deployed operators
// the player's deployed operators ([ASSUMED] an operator
// forced out at the start of 联防 — down on its tile — does
// not count; the −50 % still covers its timer, Battle.start)
// env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or
// summon) takes heals it `value` (not HP loss 流失, not gauge
// fills, not while a 禁疗 status is on)
@@ -394,7 +396,10 @@ function prepGateOn(battle, ps, ref, gate) {
return n == null ? false : benchGate(gate, n);
}
/** Largest number of the player's deployed operators sharing one row. */
/**
* Largest number of the player's deployed operators sharing one row ([ASSUMED] a knocked-out operator lying on its tile —
* one forced out at the start of 联防 — is not deployed and does not count).
*/
function maxRowOps(battle, pid) {
const rows = new Map();
for (const u of battle.allyUnits) if (isOwnOp(u, pid) && u.alive && u.deployed) rows.set(u.tileR, (rows.get(u.tileR) ?? 0) + 1);
+5 -2
View File
@@ -354,9 +354,12 @@ function tickMire(battle, st, u) {
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } });
}
/** Terrain code under a unit (0 = none). Flyers and hidden units ignore ground terrain; smog only helps allies. */
/**
* Terrain code under a unit (0 = none). Air units (Unit.isFlying: flyers, hovering 近地悬浮 and levitated enemies) and
* hidden units ignore ground terrain; smog only helps allies.
*/
function terrainFor(battle, st, u) {
if (u.hidden || u.motion === 'FLY') return 0;
if (u.hidden || u.isFlying) return 0;
let k;
if (u.side === 'ally') k = u.tileR * COLS + u.tileC;
else {
+158 -56
View File
@@ -11,6 +11,8 @@
// list [abilities], times, hitShield, atkType, immune, … }`. An ability is a plain object with optional methods
// spawn(b,e,a,ab) · dealt(c,…) ('damaged' by one of e's ATTACKS on an ally, non-element) · taken(c,…) ('damaged' on e,
// non-element) · hitIn(c,…) ('hit' on e, late: after every other hit handler) · hitOut(c,…) ('hit' from e, early) ·
// (`c.source` is null for 无来源 damage — element bursts; target-side reactions that must still fire, e.g. damage
// sharing, read `c.source || c.credit`, `credit` naming the unit that filled the gauge) ·
// before(c,…) (beforeAttack of e) · attack(c,…) · blocked(c,…) · killed(c,…) (return true = revived; the kill is then
// hidden from later 'kill' handlers) · death(c,…) · status(c,…) (statusApplied on e) · burst(c,…) (elementBurst on e)
// · tick(b,e,a,dt) every `iv` s (every tick when iv is 0) · skill: `cd`/`left`(=icd)/`cond`/`fire` (cooldown runs
@@ -22,7 +24,11 @@
// rebirth of Reborn.duration s, untargetable and inert, then the new form); artsBarrier() — "吸收法术伤害的屏障"
// (absorbs arts after RES); frontGuard() — "来自正面的伤害降低" (facing = walking direction or the bigger crowd);
// unbalanced() — 失衡 detection (displacement beyond the enemy's own speed; engine displace() has no hook);
// attackLoop() — normal attacks for data-unarmed enemies once a form arms them.
// attackLoop() — normal attacks for data-unarmed enemies once a form arms them; float() — 近地悬浮 (an air unit that
// keeps its ground path, Unit.isFlying; kitSyufo / kitParrot lose it when stunned). `e.profile.canTarget(ally)` = the
// enemy's own target rule (只攻击地面单位 …), applied by the engine to the candidates before its priority order; a
// special priority (优先攻击防御力最高的… / 生命上限最高的…) sorts by its key, ties by taunt then latest deployed
// (targeting.js aggroCmp — PRTS 索敌: 特殊优先级 → 仇恨值).
//
// Special types (factions.json):
// FLY — engine (FLY motion, ranged-only targeting). Flyer kits below (御4, 护障, 寒霜, 萨科塔之翼/眼, 黑云 …).
@@ -49,7 +55,7 @@
// Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss.
import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE } from '../constants.js';
import { canTargetAlly, sortAllyTargets, sortEnemyTargets } from '../targeting.js';
import { canTargetAlly, sortAllyTargets, aggroCmp } from '../targeting.js';
import { mitigate } from '../damage.js';
// ---------------------------------------------------------------------------------------------------------------
@@ -59,6 +65,9 @@ import { mitigate } from '../damage.js';
export const EROSION = 'erosion';
/** Official erosion burst on operators (gamedata_const termDescriptionDict ba.dt.erosion; engine constants ELEMENT.erosion.ally). */
export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDown, physDamage: ELEMENT.erosion.ally.damage });
/** 假想敌:淤困: element damage its host's burst spreads to the allies of the 4 tiles around (PRTS 假想敌:淤困 "附着对象元素爆发时
* …对附着对象及周围4格内的所有我方单位…造成1000同类型元素损伤"; the host itself is in its 爆发冷却). */
const PARASITE_SPREAD = 1000;
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
const BOOM_RADIUS = 1.25;
/** Pollution zone radius of 萨卡兹枯朽战车's ranged 污染秽蚀 [ASSUMED]. */
@@ -115,7 +124,7 @@ const ELK_FAIL_SCALE = 2;
const STATE = new WeakMap();
function stOf(b) {
let st = STATE.get(b);
if (!st) { st = { lpLoss: 0, floats: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); }
if (!st) { st = { lpLoss: 0, freedAll: false, deathWatch: new Set() }; STATE.set(b, st); }
return st;
}
@@ -252,21 +261,7 @@ function onDamaged(b, c) {
function onBeforeAttack(b, c) {
const a = c.attacker;
if (!a) return;
if (a.side === 'enemy') { if (a.mem.ab) dispatch(b, a, 'before', c); return; }
// 近地悬浮 (ba.float "无法被阻挡或近战攻击"): melee operators cannot hit floating enemies — they pick others instead
if (stOf(b).floats > 0 && a.profile && a.profile.attack !== 'ranged' && c.targets) {
const ok = (t) => !(t.mem && t.mem.ab && t.mem.ab.float);
const kept = c.targets.filter(ok);
if (kept.length === c.targets.length) return;
if (!kept.length && a.rangeKeys && c.targets.some((t) => t.side === 'enemy')) {
const prof = c.profile || a.profile;
const cands = b.enemiesInKeys(a.rangeKeys, a, prof).filter(ok);
sortEnemyTargets(b, a, cands, prof.priority);
kept.push(...cands.slice(0, Math.max(1, Math.floor(prof.maxTargets || 1))));
}
c.targets = kept;
}
if (a && a.side === 'enemy' && a.mem.ab) dispatch(b, a, 'before', c);
}
function onKill(b, c) {
@@ -467,16 +462,20 @@ export function expose(b, u, dur, scale) {
b.fx('expose', { x: u.x, y: u.y, id: u.id });
}
/** Final HP damage the pipeline would deal for a phys/arts/true `dmg` before shields (mitigation is linear in the amount). */
/**
* Final HP damage the pipeline would deal for a phys/arts/true/elemental `dmg` before shields (mitigation is linear in
* the amount) — the same terms as damage.js dealDamage (元素伤害: 元素抗性 + elementalTakenMul, no dmgTakenMul).
*/
export function expectedFinal(src, tgt, dmg) {
const ts = tgt.s, ss = src && src.s ? src.s : null, ty = dmg.type;
const ts = tgt.s, ss = src && src.s && !dmg.sourceless ? src.s : null, ty = dmg.type; // 无来源 (bursts): no source stats
const v = mitigate(dmg.amount, ty, ts, {
defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0), defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0), resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0),
elementalRes: ty === 'elemental' ? (tgt.def?.epDamageResistance ?? 0) : 0,
});
let mul = (dmg.mul ?? 1) * ts.dmgTakenMul;
let mul = (dmg.mul ?? 1) * (ty === 'elemental' ? 1 : ts.dmgTakenMul); // 脆弱 skips 元素伤害 (damage.js)
if (ss) mul *= ss.dmgDealtMul * (ty === 'phys' ? ss.physDealtMul : ty === 'arts' ? ss.artsDealtMul : 1);
mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ts.trueTakenMul;
mul *= ty === 'phys' ? ts.physTakenMul : ty === 'arts' ? ts.artsTakenMul : ty === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
const f = v * mul;
return Number.isFinite(f) && f > 0 ? f : 0;
}
@@ -608,12 +607,23 @@ const refraction = (res, hpPct = 0) => ({
},
});
/** 近地悬浮: unblockable, immune to melee attacks. */
const float = () => ({
spawn(b, e, a, ab) { ab.float = true; stOf(b).floats++; b.addBuff(e, { key: 'ab:float', flags: { unblockable: true }, persist: true }); },
hitIn(c, b, e) { const s = c.source; if (c.dmg.isAttack && s && s.side === 'ally' && s.profile && s.profile.attack !== 'ranged') c.dmg.cancel = true; },
death(c, b) { stOf(b).floats = Math.max(0, stOf(b).floats - 1); },
});
/**
* 近地悬浮 (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义: "此类效果开始时,单位强制解除阻挡,随后行动方式变为飞行(算作空中
* 单位)… 无论飞行与否,单位只会采用地面寻路"): while floating the enemy carries the 'ab:float' buff — unblockable, an air unit for
* every targeting / ground-only rule (flag `float` ⇒ Unit.isFlying: melee operators, 迷迭香's ground-only shots and
* splash, ground traps and terrain skip it; ranged attacks hit it) with 失衡免疫 (`noDisplace`, "初始模式:近地悬浮,失衡
* 免疫" on the enemies' PRTS pages) — its `motion` stays WALK, so it keeps the ground path (and 浮空 can still lift it:
* Battle.applyStatus refuses data flyers only). Losing the float is each
* enemy's own ability (PRTS; 缚地 "使部分近地悬浮敌人掉落" is "实为敌人自身的能力"): kitSyufo, kitParrot.
*/
const FLOAT_KEY = 'ab:float';
const setFloat = (b, e, on) => {
if (on) b.addBuff(e, { key: FLOAT_KEY, flags: { unblockable: true, float: true, noDisplace: true }, persist: true });
else b.removeBuff(e, FLOAT_KEY);
};
const float = () => ({ spawn(b, e) { setFloat(b, e, true); } });
/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */
export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']);
/** "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear). */
const selfFear = (ab) => ({
@@ -1030,12 +1040,77 @@ function kitDsubrl(ab, e) {
return [hitAllInRadius(e.base.rangeRadius || 1.6), ep('neural', T(ab, 'EpDamage.ep_damage_ratio') ?? 0), resist(['sluggish'])];
}
/** 掠海漂移体 爬行模式: the stun it takes on dropping (PRTS "进入爬行模式并晕眩0.5秒"; not in its blackboard). */
const SYUFO_CRAWL_STUN = 0.5;
/** 吉兆飞鳞 【失温坠落】: the stun when a freeze ends (PRTS "冻结结束时,获得0.05秒晕眩"). */
const PARROT_CHILL_STUN = 0.05;
/**
* 掠海漂移体 (PRTS): 初始模式 近地悬浮 + 失衡免疫, 不会攻击飞行单位; 受晕眩/无法行动/沉睡/冻结/缚地影响后进入爬行模式 for
* good and is stunned 0.5 s — a ground unit (blockable, melee operators hit it) that "仅进行阻挡攻击" (only its blocker).
* The engine has 晕眩 / 冻结 / 沉睡 (no operator here applies 无法行动 or 缚地). Erosion on every attack.
*/
function kitSyufo(ab) {
return [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), {
spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; },
status(c, b, e, a) {
if (a.crawl || !(c.status === 'stun' || c.status === 'freeze' || c.status === 'sleep')) return;
a.crawl = true;
setFloat(b, e, false);
e.profile.melee = true;
b.applyStatus(e, 'stun', { duration: SYUFO_CRAWL_STUN, source: null });
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'crawl' });
},
}];
}
/**
* 吉兆飞鳞 (PRTS): 近地悬浮 (失衡免疫), no normal attack; 受晕眩/无法行动/沉睡/缚地影响后进入晕眩模式 — the float is off, it
* cannot be blocked and is stunned `Stun.duration` (8) s — and floats again once none of those effects holds it
* (初始模式); 冻结 grounds it only when the freeze ends (【失温坠落】: 0.05 s stun). 首次受到伤害后 its speed is ×
* `M0SpeedUp.move_speed` for `M0SpeedUp.duration` s ("最终提升至300%"; never in 晕眩模式, dropped when its mode
* changes). 搬运模式 (carrying a 寻险水手) needs a level checkpoint (`ThrowEnemy.checkpoint` 0 = never) — not in this mode.
*/
function kitParrot(ab) {
const down = T(ab, 'Stun.duration') ?? 0;
return [float(), {
taken(c, b, e, a) {
if (a.ran || a.down) return;
a.ran = true;
b.addBuff(e, { key: 'ab:parrotRun', duration: T(ab, 'M0SpeedUp.duration') ?? 0, mods: { moveMul: T(ab, 'M0SpeedUp.move_speed') ?? 1 } });
},
status(c, b, e, a) {
if (c.status === 'freeze') { a.chill = true; return; }
if (a.down || !(c.status === 'stun' || c.status === 'sleep')) return;
a.down = true;
setFloat(b, e, false);
b.removeBuff(e, 'ab:parrotRun');
b.addBuff(e, { key: 'ab:parrotDown', flags: { unblockable: true }, persist: true });
if (down > 0) b.applyStatus(e, 'stun', { duration: down, source: null });
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'grounded' });
},
tick(b, e, a) {
if (a.chill && !e.s.flags.freeze) { a.chill = false; b.applyStatus(e, 'stun', { duration: PARROT_CHILL_STUN, source: null }); }
// "离开上述异常效果影响后进入初始模式": s.flags.stun also holds while asleep / frozen (units.js), so a sleep that
// outlasts the mode stun keeps it down
if (a.down && !e.s.flags.stun) {
a.down = false;
b.removeBuff(e, 'ab:parrotDown');
b.removeBuff(e, 'ab:parrotRun');
setFloat(b, e, true);
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'float' });
}
},
}];
}
function kitTank(ab) {
const every = (T(ab, 'Empty.sp') ?? 2) + 1, melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
const s = ab.sk.PollutedRangedAtk ? ab.sk.PollutedRangedAtk.bb : {};
return [{
spawn(b, e, a) { a.n = 0; },
before(c, b, e) { c.targets = c.targets.filter((u) => u.ground); }, // 只攻击地面单位
// 只攻击地面单位 — PRTS 萨卡兹枯朽战车 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter,
// ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a low tile is no target either)
spawn(b, e, a) { a.n = 0; e.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
hitOut(c, b, e) { if (c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= melee; }, // 近战攻击造成更高伤害
attack(c, b, e, a) {
a.n++;
@@ -1113,17 +1188,28 @@ function kitJazz(ab, e) {
spawn(b, e2) { e2.profile.noAttack = true; },
tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击
}, s ? {
sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2),
// 狂欢式演奏 (PRTS 节日爵士乐手, 反击模式): "仅攻击范围内存在我方单位时可触发:锁定目标持续施法,最多持续10.6s,每0.5s对目标
// 造成攻击力20%的法术伤害和攻击力10%的灼燃损伤 ※施法期间受到沉默影响后,立即结束技能" — ONE locked target (its own
// priority: the blocker first), floor(10.6 / 0.5) = 21 ticks at 0.5 … 10.5 s. The channel also ends when that target
// is gone, makes no normal attacks meanwhile and is never re-cast over itself — the 10 s cooldown (enemy_database
// skill 'fire') runs out mid-channel, the next cast waits for its end [ASSUMED "持续施法", as 死亡之眼].
sil: true, cd: s.cd, icd: s.icd, cond: (b, e2) => revealed(e2) && !(e2.mem.jazzChannel && !e2.mem.jazzChannel.cancelled) && targetsNear(b, e2, r).length > 0,
fire(b, e2) {
const dur = s.bb['enemy_cnvsax[cd].duration'] ?? 0, iv = s.bb.hit_interval ?? 0.5;
let left = dur;
b.fx('zone', { x: e2.x, y: e2.y, r, dur, kind: 'jazzFire', id: e2.id });
const t = byPriority(e2, targetsNear(b, e2, r))[0];
if (!t || !(dur > 0) || !(iv > 0)) return;
const n = Math.max(1, Math.floor(dur / iv + 1e-9));
const buff = b.addBuff(e2, { key: 'ab:channel', duration: dur, flags: { disarm: true } });
let k = 0;
const end = () => { h.cancel(); e2.mem.jazzChannel = null; if (buff) b.removeBuff(e2, buff); };
const h = b.every(iv, () => {
if (!e2.alive || e2.s.flags.silence) { h.cancel(); return; }
for (const u of b.alliesInRadius(e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); }
left -= iv;
if (left <= 1e-6) h.cancel();
if (!e2.alive || e2.s.flags.silence || !t.alive || !t.deployed) { end(); return; }
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'jazzFire' });
hurt(b, e2, t, e2.s.atk * (s.bb.atk_scale ?? 0), 'arts');
elem(b, e2, t, 'burn', e2.s.atk * (s.bb.ep_damage_ratio ?? 0));
if (++k >= n) end();
}, { owner: e2 });
e2.mem.jazzChannel = h;
},
} : null];
}
@@ -1204,6 +1290,21 @@ function kitExposeOnDeath(ab) {
return [{ sil: true, death(c, b, e) { if (c.reason !== 'killed') return; b.fx('explode', { x: e.x, y: e.y, r, kind: 'expose' }); for (const u of b.alliesInRadius(e.x, e.y, r)) expose(b, u, dur, scale); } }];
}
/**
* 淤困's "受到的元素损伤提高至130%" on its host: an `elementHit` multiplier (SIM.md §7.2 — every "受到的元素损伤±N%"; on an
* operator at priority 20, before a 损伤屏障), never 元素伤害. One battle-wide handler, installed with the first host.
*/
function ensureParasiteHook(b) {
const st = stOf(b);
if (st.parasiteHook) return;
st.parasiteHook = true;
b.on('elementHit', (c) => {
const bf = c.target && c.dmg && c.dmg.type === 'element' ? c.target.findBuff('ab:parasite') : null;
const m = bf && bf.data ? bf.data.epMul : 1;
if (m > 0 && m !== 1) c.dmg.mul *= m;
}, { priority: 20 });
}
function kitParasite(ab) {
const scale = T(ab, '1.atk_scale') ?? 0, elMul = T(ab, '1.ep_damage_scale') ?? 1;
return [taunt(-1), {
@@ -1213,9 +1314,11 @@ function kitParasite(ab) {
if (a.host && a.host !== host) { b.removeBuff(a.host, 'ab:parasite'); a.host = null; }
if (!host || a.host === host) return;
a.host = host;
ensureParasiteHook(b);
b.fx('beam', { x: e.x, y: e.y, from: e.id, to: host.id, kind: 'parasite' });
b.addBuff(host, {
key: 'ab:parasite', visible: true, interval: 1, mods: { elemTakenMul: elMul }, data: { src: e, spread: e.s.atk * elMul },
// "受到的元素损伤提高至130%": a 元素损伤 multiplier on the element hit (ensureParasiteHook), not 元素伤害
key: 'ab:parasite', visible: true, interval: 1, data: { src: e, spread: PARASITE_SPREAD, epMul: elMul },
onTick: ({ battle, unit }) => { if (e.alive) battle.dealDamage(e, unit, { amount: e.s.atk * scale, type: 'arts', canDodge: false, tags: ['enemyAbility', 'parasite'] }); },
});
},
@@ -1295,12 +1398,9 @@ function kitRegen(ab) {
function kitSteal(ab) {
const n = T(ab, 'DamageOrBullet.attack@minus_bullet') ?? 1;
return [selfFear(ab), {
// PRTS “萨科塔之眼”: "不会攻击飞行单位" (the 炎佑 is one) — its next ground target in range instead
before(c, b, e) {
if (!c.targets || !c.targets.some((t) => t.isFlying)) return;
const l = targetsNear(b, e, e.base.rangeRadius).filter((t) => !t.isFlying);
c.targets = byPriority(e, l).slice(0, 1);
},
// PRTS “萨科塔之眼” 天赋: "不会攻击飞行单位" (the 炎佑 dragon is one) — a candidate filter (ai.js enemyAttack): its next
// ground target in range instead
spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; },
hitOut(c, b, e) {
if (!c.dmg.isAttack) return;
const t = c.target;
@@ -1389,10 +1489,11 @@ function kitFlameVine(ab) {
dealt(c, b, e, a) {
const t = c.target;
if (a.first) {
// 首次攻击对目标和周围造成法术伤害和灼燃损伤, then the ATK ramp ends
// 首次攻击 (PRTS 灼藤): every other ally of the target's 3×3 (range x-4, tile test) takes 100 % ATK arts splash and
// everyone there 25 % ATK burn; then the ATK ramp ends and later attacks add 20 %
a.first = false;
b.fx('explode', { x: t.x, y: t.y, r: 1, kind: 'flameVine' });
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', 1)) { if (u !== t) hurt(b, e, u, e.s.atk, 'arts'); elem(b, e, u, 'burn', e.s.atk * first); }
b.fx('explode', { x: t.x, y: t.y, r: 1.5, kind: 'flameVine' });
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'box', 1)) { if (u !== t) hurt(b, e, u, e.s.atk, 'arts'); elem(b, e, u, 'burn', e.s.atk * first); }
b.removeBuff(e, 'ab:pow');
} else elem(b, e, t, 'burn', e.s.atk * normal);
},
@@ -1496,14 +1597,14 @@ function kitLeaderMisc(key, ab, e) {
return [bar, lowHpBuff(T(ab, 'enrage.hp_ratio') ?? 0.5, { dmgDealtMul: T(ab, 'enrage.damage_scale') ?? 1 }),
// 【唱沙】 the highest-max-HP unit on the field and everything in its cross: physical `damage`
skill(ds, (b, e2) => {
const t = allTargets(b, e2).sort((p, q) => q.s.maxHp - p.s.maxHp || q.deploySeq - p.deploySeq)[0];
const t = allTargets(b, e2).sort((p, q) => q.s.maxHp - p.s.maxHp || aggroCmp(p, q))[0];
if (!t) return;
b.fx('telegraph', { x: t.tileC, y: t.tileR, r: DRIFT_REACH, kind: 'driftSand', tiles: 'plus', id: e2.id });
for (const u of alliesInTiles(b, t.tileR, t.tileC, 'plus', DRIFT_REACH)) hurt(b, e2, u, ds.bb.damage ?? 0, 'phys');
}, { cond: (b, e2) => allTargets(b, e2).length > 0 }),
// 【沙狱】 the lowest-max-HP unit and those around it: ATK −`atk` and `damage` arts per second for `duration` s
skill(ss, (b, e2) => {
const t = allTargets(b, e2).sort((p, q) => p.s.maxHp - q.s.maxHp || q.deploySeq - p.deploySeq)[0];
const t = allTargets(b, e2).sort((p, q) => p.s.maxHp - q.s.maxHp || aggroCmp(p, q))[0];
if (!t) return;
const dur = ss.bb.duration ?? 0, x = t.x, y = t.y;
b.fx('zone', { x, y, r: SANDSTORM_RADIUS, dur, kind: 'sandStorm', id: e2.id });
@@ -1771,7 +1872,7 @@ function kitWarden(ab) {
function kitXi(ab) {
const cross = ab.sk.CrossAttack, sb = ab.sk.ShieldBurst, sb2 = ab.sk.ShieldBurstReborn;
const P = { form2: false };
const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || q.deploySeq - p.deploySeq);
const byDist = (b, e) => allTargets(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y) || aggroCmp(p, q));
const crossAt = (b, e, t) => {
const r0 = t.tileR, c0 = t.tileC;
b.fx('telegraph', { x: c0, y: r0, r: XI_CROSS_REACH, kind: 'xiCross', tiles: 'plus', id: e.id });
@@ -1982,7 +2083,7 @@ export const KITS = Object.freeze({
enemy_1389_winbab_2: kitStealth, // 访问团强攻冠军 · stealth (供暖器 priority n/a)
enemy_1404_msnip: kitCrossbow, // 重弩突袭者 · stealth + 直击 (row/column bolt: arts + stun, reveals itself)
enemy_10031_cnvsld: kitStealth, // 业余竞演者 · stealth
enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth, attacks only when revealed; fire aura (arts + burn)
enemy_10034_cnvsax: kitJazz, // 节日爵士乐手 · stealth, attacks only when revealed; channel on one target (arts + burn)
enemy_10042_prtrop: kitStealth, // 架桥船工 · stealth (bridges n/a)
enemy_10042_prtrop_2: kitStealth, // 扶桥老手 · stealth
enemy_9008_acbunn: kitBoneSpike, // 假想敌:骨刺 · stealth; hits 3 targets while stealthed
@@ -2033,15 +2134,15 @@ export const KITS = Object.freeze({
enemy_1305_mhslim: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 灼热源石虫 · burn on hit
enemy_1305_mhslim_2: kitEp('burn', 'EpDamage.attack@ep_damage_ratio'), // 炽焰源石虫 · burn on hit
enemy_2021_syfish: kitEp('erosion', 'EpDamage.attack@ep_damage_ratio'), // 骨海漂流体 · erosion on hit
enemy_2025_syufo: (ab) => [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0)], // 掠海漂移体 · 近地悬浮 + erosion
enemy_2025_syufo: kitSyufo, // 掠海漂移体 · 近地悬浮 (drops to 爬行模式 when stunned) + erosion
enemy_1229_darmy: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军战士 · apoptosis on hit
enemy_1229_darmy_2: kitEp('apoptosis', 'epdamage.attack@ep_damage_ratio'), // 萨卡兹王庭军精锐战士 · apoptosis on hit
enemy_1275_dwlock_2: kitDeathEye, // 萨卡兹王庭军精锐术师 · apoptosis on hit + DeathEye channel → group apoptosis
enemy_1439_dslntf: kitNucleus, // 元核孽生者 · first damage: combat state (speed ×, neural pulse around)
enemy_1439_dslntf_2: kitNucleus, // 异光体孽生者 · same
enemy_9007_acelem: kitParasite, // 假想敌:淤困 · taunt −1; parasitises its blocker (arts/s, element taken ×, burst spreads)
enemy_9007_acelem: kitParasite, // 假想敌:淤困 · taunt −1; parasitises its blocker (arts/s, element taken ×, its burst spreads 1000)
enemy_10065_ftzlc: kitIgnitable, // 卷心籽 · ignited by 灼藤 → arts death blast
enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (AoE arts + burn), burn on hit, ignites 卷心籽
enemy_10067_ftsjc: kitFlameVine, // 灼藤 · ATK ramps until the 1st attack (3×3 arts + burn), burn on hit, ignites 卷心籽
// --- DOT 持续
enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · pulse hits every ally in range + neural; 抵抗, immune 停顿
@@ -2254,7 +2355,7 @@ export const KITS = Object.freeze({
},
}];
},
enemy_10045_parrot: (ab) => [float(), { taken(c, b, e, a) { if (a.on) return; a.on = true; b.addBuff(e, { key: 'ab:parrotRun', duration: T(ab, 'M0SpeedUp.duration') ?? 0, mods: { moveMul: 1 + (T(ab, 'M0SpeedUp.move_speed') ?? 0) } }); } }], // 吉兆飞鳞 · 近地悬浮; sprints when first hit
enemy_10045_parrot: kitParrot, // 吉兆飞鳞 · 近地悬浮 (grounded 8 s when stunned); sprints when first hit
enemy_10087_hlchgr: (ab) => [{ // 圣堂剑士 · spends ammo (every 6 s, max 5) for permanent speed/ATK
iv: T(ab, 'SkillTrigger.interval') ?? 6,
tick(b, e, a) {
@@ -2386,7 +2487,8 @@ function chaliceShare(b, c) {
if (!a || !(a.share > 0) || Math.hypot(o.x - t.x, o.y - t.y) > a.r) continue;
const part = c.dmg.amount * a.share;
c.dmg.amount -= part;
b.dealDamage(c.source, o, { amount: part, type: c.dmg.type, canDodge: false, tags: [...(c.dmg.tags || []), 'chalice'] });
// a 无来源 burst's share stays 无来源, credited like the burst (damage.js)
b.dealDamage(c.dmg.sourceless ? c.credit : c.source, o, { amount: part, type: c.dmg.type, canDodge: false, sourceless: c.dmg.sourceless, tags: [...(c.dmg.tags || []), 'chalice'] });
break;
}
}
+7 -8
View File
@@ -14,8 +14,9 @@
// * "同一行有3名干员" / "同一行每有1名干员" count the trait's owner (board chess of that row, self included).
// * POSITION "使自身及身后/身前一格干员的已激活盟约分别各层数+N": per target operator (a shared bond gets +N twice).
// * 瑰盐 "优先…更靠上的和更靠右的": highest row first (DESIGN §3.1: row 0 is the bottom), then highest column.
// * 购买价格 (SERVER_CHESS_PRICE) replaces the base price; earlier price deltas (远见 discount …) are kept.
// * 至简 costs bb.price = 2 (its text says 1; blackboards are authoritative).
// * 购买价格为N (SERVER_CHESS_PRICE): bb.price is a discount off the tier price — 至简 (Ⅲ, 3) has 2 → 1, 红豆 (Ⅰ, 2)
// has 1 → 1, exactly the N both official texts give (user playtest #5: 至简 costs 1). The dispatcher runs it before
// every other onPrice modifier, so 远见's discount (never below 1) and strategy caps act on the lowered price.
// * [ASSUMED] 余 SERVER_MOST_BOND: ties between most-member bonds are shuffled; the chess is a copy-weighted pool roll
// of any tier (the text gives no tier cap); a bond without an available chess falls through to the next tied one.
// * [ASSUMED] 松果: the "免费特殊招募" is a free pick-one offer of `rewardOffer.count` (3) chess of the pool's tier.
@@ -196,16 +197,14 @@ H.SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT = {
},
};
// "购买价格为N": the trait replaces the chess's base price (bb.price — 至简's data says 2 although its text says 1;
// blackboards are authoritative). Modifiers that ran earlier in the dispatch (bonds such as 远见's discount) keep their
// delta relative to the base price instead of being overwritten.
// "购买价格为N": bb.price is the discount off the chess's tier price — 至简 (Ⅲ, 3 资金) carries 2 and 红豆 (Ⅰ, 2 资金)
// carries 1, and both texts say 购买价格为1 (read as the new price, 至简 cost 2; user playtest #5). The dispatcher runs
// this first on onPrice (effectsMeta.js), so bonds (远见 −1, never below 1) and strategies see the lowered price.
H.SERVER_CHESS_PRICE = {
onPrice(ctx, ev) {
const p = ctx.source.bb.price;
if (!Number.isFinite(p) || !ev) return;
const base = ev.slot && Number.isFinite(ev.slot.basePrice) ? ev.slot.basePrice : null;
const cur = Number.isFinite(Number(ev.price)) ? Number(ev.price) : base;
ctx.setPrice(base == null || cur == null ? p : p + (cur - base));
ctx.modifyPrice(-p);
},
};
+4 -3
View File
@@ -35,7 +35,7 @@ import {
num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile,
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf,
} from '../support/index.js';
import { mitigate } from '../../damage.js';
import { mitigate, hasHp } from '../../damage.js';
// =====================================================================================================================
// data helpers
@@ -244,9 +244,10 @@ function hammerAcquire(battle, rt, u) {
hs.scope = S;
hs.aspdCur = 0;
S.every(0.5, () => hammerRefresh(battle, rt, u, hs), { immediate: true });
// 灼燃: arts damage also deals burn (元素损伤) = damage_scale × damage × m
// 灼燃: arts damage also deals burn (元素损伤) = damage_scale × damage × m — not on a killing blow (the hook runs
// before the kill, at 0 HP: no burst on the corpse)
S.on('damaged', (c) => {
if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !c.target.alive) return;
if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !hasHp(c.target)) return;
const p = hammerParams(hs, 'burn');
if (!p) return;
const m = hammerMul(battle, rt, u, hs, 'burn');
+13 -31
View File
@@ -7,8 +7,8 @@
// Profession defaults (professions.js) are reused: kits only add what the trait text / module says beyond them.
//
// Covered (normal + elite): 1_01 隐现 1_02 角峰 1_03 惊蛰 1_04 深巡 1_05 红豆(H) 1_06 刺玫 1_07 普罗旺斯 1_08 德克萨斯
// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_15 盟约·辅助干员(H) 1_16 锡人(H) 1_17 深靛
// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.)
// 1_09 跃跃 1_10 古米 1_11 地灵(H) 1_12 艾丝黛尔 1_13 波登可 1_14 格雷伊 1_16 锡人(H) 1_17 深靛
// 1_18 宴 1_19 野鬃 1_20 雷蛇. (H = hidden in the shop pool, still authored.) 1_15 盟约·辅助干员(H) lives in tier6.js.
// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's
// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite);
// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active
@@ -22,6 +22,7 @@
import { COLS } from '../../constants.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
import { frontOf, offsetTile } from '../../dir.js';
import { bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
// =================================================================================================================
// shared helpers (named exports; content/index.js only merges the default export)
@@ -51,8 +52,10 @@ export const moduleOn = (chess) => !!(chess?.module && chess.module.active);
export const up = (u) => !!u && u.alive && u.deployed;
export const posKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
/** Chebyshev tile distance between two units. */
export const cheb = (a, b) => Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x)));
/** Chebyshev tile distance between two units (a huge enemy: from the nearest tile it occupies — sim/body.js). */
export const cheb = (a, b) => (b.hitArea ? bodyTileReach(b, Math.round(a.y), Math.round(a.x))
: a.hitArea ? bodyTileReach(a, Math.round(b.y), Math.round(b.x))
: Math.max(Math.abs(Math.round(a.y) - Math.round(b.y)), Math.abs(Math.round(a.x) - Math.round(b.x))));
/** Normal attack hit on its primary target (no splash, no chain jump). */
export const isMainHit = (dmg) => !!dmg && dmg.isAttack && !dmg.isSplash && !(dmg.tags && dmg.tags.includes('chain'));
/** Damage ctx caused by an enemy's attack. */
@@ -152,7 +155,7 @@ export function installReveal(battle, unit, interval = 0.2) {
battle.every(interval, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !e.s.flags.stealth || !unit.rangeKeySet.has(posKey(e))) continue;
if (!e.alive || e.hidden || !e.s.flags.stealth || !bodyInKeys(e, unit.rangeKeySet)) continue;
const was = !!e.s.flags.reveal;
if (battle.applyStatus(e, 'reveal', { duration: interval + 0.1, source: unit }) && !was) battle.fx('reveal', { x: e.x, y: e.y, id: e.id });
}
@@ -544,7 +547,7 @@ export default {
}
if (!isMainHit(dmg)) return;
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
const front = Math.round(target.y) === fr && Math.round(target.x) === fc;
const front = bodyOnTile(target, fr, fc);
const p = front ? num(t.prob2) : num(t.prob);
if (p > 0 && battle.rng.chance(p)) {
dmg.amount *= num(t.atk_scale, 1);
@@ -557,8 +560,8 @@ export default {
// ---------------------------------------------------------------------------------------------------------------
// 1_08 德克萨斯 剑雨: +cost DP; every enemy around (skill grid) takes two hits of atk_scale × ATK arts and is
// stunned `stun` s. 战术快递: +cost initial DP. (Elite hidden runtime_cost −4 "首次部署时部署费用-4": the initial
// deployment is free in battle ⇒ no effect.)
// stunned `stun` s — air units too (PRTS 备注 "※可对空"). 战术快递: +cost initial DP. (Elite hidden runtime_cost −4
// "首次部署时部署费用-4": the initial deployment is free in battle ⇒ no effect.)
// Alternate S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once. An AUTO skill keeps its normal rule in this mode
// (research 03 §1.4: only MANUAL skills are converted by the trigger table) ⇒ it fires as soon as SP is full.
chess_char_1_08_a: (bb, chess, def) => ({
@@ -724,7 +727,7 @@ export default {
battle.on('death', (ctx) => {
const e = ctx.unit;
if (e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit)) return;
const near = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0).includes(posKey(e)) : cheb(unit, e) <= 1;
const near = grid ? bodyInKeys(e, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : cheb(unit, e) <= 1;
if (near) battle.heal(unit, unit, unit.s.maxHp * num(t.hp_ratio), { self: true, tags: ['talent'] });
}, { owner: unit });
if (tb.damage_resistance != null) {
@@ -800,28 +803,7 @@ export default {
};
},
// ---------------------------------------------------------------------------------------------------------------
// 1_15 盟约·辅助干员 (hidden, band 优等生) 战术咏唱·双型: ASPD +attack_speed, attack@max_target targets.
// 迭代元素: each hit adds ep_damage_ratio × ATK element damage — 神经 first, else 灼燃, else 凋亡 (the first element
// not bursting on the target); targets not in a burst are preferred. Elite module: ep_damage_ratio_boss vs
// ELITE/BOSS enemies.
chess_char_1_15_a: (bb, chess) => {
const t = talentBb(chess, 0);
const ratio = num(t.ep_damage_ratio), ratioBoss = num(t.ep_damage_ratio_boss, ratio);
return {
trait: {
priority: 'notBurst',
afterHit(battle, u, target) {
if (!target || !target.alive || target.side !== 'enemy' || !(ratio > 0)) return;
const el = ['neural', 'burn', 'apoptosis'].find((k) => !target.findBuff(k + 'Burst'));
if (!el) return;
const r = target.def?.rank === 'ELITE' || target.def?.rank === 'BOSS' || target.isBoss ? ratioBoss : ratio;
battle.dealDamage(u, target, { type: 'element', element: el, amount: u.s.atk * r, tags: ['talent'] });
},
},
skill: { kind: 'duration', mods: { aspd: num(bb.attack_speed) }, targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) } },
};
},
// 1_15 盟约·辅助干员 (hidden, band 优等生): its kit is tier6.js `pithst` (迭代元素 — one implementation only).
// ---------------------------------------------------------------------------------------------------------------
// 1_16 锡人 (hidden tier-1 entry of chess_char_2_19): see tinmanKit.
+24 -16
View File
@@ -15,8 +15,9 @@
import { COLS, ELEMENT_GAUGE_MAX } from '../../constants.js';
import { canTargetEnemy } from '../../targeting.js';
import { bodyInKeys } from '../../body.js';
import {
num, talentBb, moduleBb, traitBb, up, posKey, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, summonTileFree, instantKind,
tinmanKit, batMod,
} from './tier1.js';
@@ -48,8 +49,8 @@ function tokenLifetime(battle, tokId, owner, desc) {
return m ? +m[1] : 0;
}
/** Largest element gauge of a unit. */
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis);
/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0);
/**
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
@@ -211,7 +212,9 @@ export default {
// ---------------------------------------------------------------------------------------------------------------
// 2_05 哈洛德 重症优先: ASPD +attack_speed, heals the ally with the heaviest element damage first; on targets whose
// gauge is over half, the element recovery is trait_scale × (the wandermedic trait: ep_heal_ratio × ATK).
// 我即军营: allies in range whose gauge is over half take −ep_damage_resistance element damage.
// 我即军营: allies in range whose gauge is over half take −ep_damage_resistance 元素损伤 — checked on the element hit
// itself (`elementHit`, a 元素损伤 multiplier: PRTS 元素 "…后续可应用元素损伤倍率提升/降低等效果"; not `elemTakenMul`,
// which is 元素伤害 / 元素脆弱); several 哈洛德 do not stack — the strongest cut applies.
// S1 治疗强化·γ型 (alt): ATK +atk; the 重症优先 target order belongs to S2 only.
chess_char_2_05_a: (bb, chess) => {
const t = talentBb(chess, 0);
@@ -235,14 +238,17 @@ export default {
if (cands.length) ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
unit.mem.haroldHalf = new Set(ctx.targets.filter((a) => elemLoad(a) > half).map((a) => a.id));
}, { owner: unit });
const r = num(t.ep_damage_resistance);
const r = Math.max(0, Math.min(1, num(t.ep_damage_resistance)));
if (r > 0) {
battle.every(0.2, () => {
if (!up(unit)) return;
for (const a of alliesInGridOf(battle, unit)) {
if (elemLoad(a) > half) battle.addBuff(a, { key: 'harold:camp', duration: 0.3, mods: { elemTakenMul: 1 - r }, source: unit, tags: ['talent'] });
}
}, { owner: unit });
battle.on('elementHit', (ctx) => {
const a = ctx.target, d = ctx.dmg;
if (!up(unit) || !a || a.side !== 'ally' || !d || d.type !== 'element' || !(elemLoad(a) > half)) return;
if (!(unit.rangeKeySet || new Set(unit.rangeKeys || [])).has(a.tileR * COLS + a.tileC)) return;
const prev = d.haroldCut || 0; // the strongest 我即军营 of the hit only
if (r <= prev) return;
d.mul *= (1 - r) / (1 - prev);
d.haroldCut = r;
}, { owner: unit, priority: 20 });
}
} }],
};
@@ -287,7 +293,7 @@ export default {
unit.mem.papyrsHook = battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !skill.active) return;
const L = unit.mem.papyrsLock;
if (L && up(L) && unit.rangeKeySet?.has(posKey(L))) ctx.targets = [L];
if (L && up(L) && bodyInKeys(L, unit.rangeKeySet)) ctx.targets = [L];
}, { owner: unit, priority: 10 });
battle.fx('buff', { x: lock.x, y: lock.y, id: lock.id, kind: 'lock' });
},
@@ -477,7 +483,7 @@ export default {
if (s2) battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active) return;
const L = unit.mem.rockLock;
if (L && L.alive && unit.rangeKeySet?.has(posKey(L)) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L];
if (L && L.alive && bodyInKeys(L, unit.rangeKeySet) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L];
else unit.mem.rockLock = ctx.targets[0] ?? null;
}, { owner: unit });
const iv = num(t.interval), max = Math.floor(num(t.max_stack_cnt)), v = num(t.atk);
@@ -497,7 +503,8 @@ export default {
// ---------------------------------------------------------------------------------------------------------------
// 2_11 风丸 纸艺·双影: lose hp_ratio of current HP, ATK +atk, summon the <替身> (overrideTokenKey) on a free melee tile
// around her for the skill's duration [ASSUMED lifetime]. 折纸生花: whenever a <替身> appears (the summon, and the
// dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts.
// dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts — air units too [ASSUMED:
// no 对空 note on PRTS; the <替身> itself "可对空"].
// Trait substitution: she fights with the <替身>'s stats (the engine already swaps in its HP; the kit swaps ATK/DEF
// with flat deltas so %-buffs still apply). Elite module (PUM-X, trait atk): while substituted, ATK +atk.
// S1 纸艺·迅击 (alt, attack SP): the next attack hits at atk_scale × ATK and she loses hp_ratio of her MAX HP (PRTS:
@@ -759,8 +766,9 @@ export default {
onStart({ battle, unit, skill }) {
if (unit.mem.sundial) battle.off(unit.mem.sundial);
const p = num(bb.prob);
unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, dmg }) => {
if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !source || source.side !== 'enemy') return;
unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, credit, dmg }) => { // 抵挡 is target-side: a 无来源 burst counts via its credit
const src = source || credit;
if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !src || src.side !== 'enemy') return;
if (battle.rng.chance(p)) { dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); }
}, 5) : null;
},
+57 -29
View File
@@ -29,6 +29,7 @@
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { COLS } from '../../constants.js';
import { bodyDist, bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
import { normalizeChess, normalizeSkill } from '../../simdata.js';
import { tacticalPoint as sharedTacticalPoint } from '../tokens.js';
@@ -80,8 +81,8 @@ function profileMul(battle, unit, target) {
/** Targetable enemies within `r` tiles of (x, y), nearest first (spawn order breaks ties), excluding `skip`. */
function enemiesAround(battle, unit, x, y, r, skip = null) {
const out = battle.enemiesInRadius(x, y, r).filter((e) => !(skip && skip.has(e)) && canTargetEnemy(unit, e, { canHitFly: true }));
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
return out.sort((a, b) => d2(a) - d2(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id));
const d = (e) => bodyDist(e, x, y);
return out.sort((a, b) => d(a) - d(b) || (a.spawnSeq ?? a.id) - (b.spawnSeq ?? b.id));
}
/**
* "范围内存在地面敌人时攻击速度+N" (能天使 MAR-Y trait, 空弦 MAR-Y talent): ASPD buff `key` while at least `cnt` ground
@@ -104,6 +105,8 @@ function groundAspd(key, aspd, cnt = 1) {
const AROUND_R = 1.5;
const alive = (u) => !!(u && u.alive && u.deployed);
const tileKeyOf = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x));
/** On a tile of `set`: an ally by its tile, an enemy by its body (every tile a huge enemy occupies — sim/body.js). */
const onTiles = (x, set) => (x.side === 'ally' ? set.has(tileKeyOf(x)) : bodyInKeys(x, set));
const gridKeys = (grid, u, ext = 0) => absoluteRangeKeys(grid || [[0, 0]], u.tileR, u.tileC, u.dir, ext);
const fx = (battle, kind, u, extra = {}) => battle.fx(kind, { x: u.x, y: u.y, id: u.id, ...extra });
const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]]) : null);
@@ -171,7 +174,7 @@ function aura(battle, unit, o) {
const list = o.side === 'ally' ? battle.allyUnits : battle.enemies;
for (const x of list) {
if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue;
if (!set.has(tileKeyOf(x)) || (o.filter && !o.filter(x))) continue;
if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue;
const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods;
if (!mods) continue;
battle.addBuff(x, { key: o.key, duration: dur, refresh: 'extend', mods, source: unit, tags: ['aura'] });
@@ -391,7 +394,8 @@ const KITS = {
// 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles)
// S1 仗义疏财 (passive, 2 coins): an attack spends a coin to heal the most injured ally (< 70 % HP) of the 8
// surrounding tiles for attack@heal_scale × ATK. S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin;
// closing it spends every coin on random enemies of the front range (atk_scale phys + small push forward).
// closing it spends every coin on random ground enemies of the front range (atk_scale phys + small push forward;
// PRTS 备注: 地面敌方单位, 弹道不可对空).
// Auto-close (the mode casts everything itself; the player's "主动关闭" is not available): once the purse is full
// (10) and an enemy stands in range. 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks).
chess_char_3_04_a: (bb, chess, def) => {
@@ -420,7 +424,7 @@ const KITS = {
if (!bomb.alive || !bomb.deployed) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue;
if (Math.round(e.x) !== bomb.tileC || Math.round(e.y) !== bomb.tileR) continue;
if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue;
// first enemy touching it; after switchT s on the field the bomb deals its damage one extra time
const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1;
for (let i = 0; i < hits && e.alive; i++) {
@@ -467,15 +471,18 @@ const KITS = {
kind: 'toggle',
attack: { hits: 2 },
onTick({ battle, unit, skill }) {
if ((unit.mem.coins ?? 0) >= full && enemiesOn(battle, unit, unit.rangeKeys).length) skill.end('manual');
if ((unit.mem.coins ?? 0) >= full && enemiesOn(battle, unit, unit.rangeKeys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual');
},
onEnd({ battle, unit, reason }) {
if (reason !== 'manual' || !unit.alive) return;
const n = unit.mem.coins ?? 0;
unit.mem.coins = 0;
let spent = 0;
// PRTS 备注: 【金币标记】 goes on "前方范围内的地面敌方单位与自身阻挡的所有单位" and the coins are "弹道(不可对空)" —
// air units (FLY, 近地悬浮, 浮空) are never paid
const ground = { ...unit.profile, canHitFly: false };
for (let i = 0; i < n; i++) {
const e = battle.rng.pick(enemiesOn(battle, unit, unit.rangeKeys));
const e = battle.rng.pick(enemiesOn(battle, unit, unit.rangeKeys, 0, ground));
if (!e) break;
spent++;
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
@@ -596,11 +603,17 @@ const KITS = {
return kit;
},
// ---- 3_06 菲莱 · 本源铁卫 — S2 冥河诅咒: stops attacking, HP +; when attacked blasts ground enemies around (arts +
// apoptosis, aoe_cd); ATK + once hit by element damage; 神河谕使: element taken −10 %, +SP on apoptosis
// 精锐 module PRP-X: own element damage ×ep_damage_scale while blocking
// ---- 3_06 菲莱 · 本源铁卫 — S2 冥河诅咒: stops attacking, HP +; when attacked blasts the ground enemies of the 3×3
// around her (PRTS 备注: range x-4; arts + ep_damage_ratio × ATK apoptosis, aoe_cd); ATK + once hit by element
// damage; 神河谕使: 元素损伤 taken −damage_resistance, +SP on apoptosis
// 精锐 module PRP-X "阻挡敌人时,自身造成的元素损伤提升15%": EVERY element fill she deals (the S2 blast, a 灼燃维式重锤
// she carries …) ×ep_damage_scale while she blocks — an `elementHit` multiplier, like 余's
// S1 灵河护佑 (TAKE_DAMAGE): HP +, clears her element gauges and gives a shield_value 损伤屏障 (absorbs element
// damage — gauge fills — until spent or the skill ends)
// 神河谕使 and the barrier act on the element hit before it lands (PRTS 备注: "减伤与技力回复效果于伤害计算前处理;即使
// 受到0点的凋亡损伤依然可以回复技力", "损伤屏障于伤害计算前、第一天赋后处理"): the talent's cut and SP first (priority 20),
// then the barrier absorbs what is left (`dmg.amount × dmg.mul`). The cut is a 元素损伤 multiplier (PRTS 元素 "受到的
// 元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱).
chess_char_3_06_a: (bb, chess, def) => {
const d = defOf(chess, def);
const t0 = talentBb(d, 0);
@@ -612,13 +625,13 @@ const KITS = {
const ep = num(bb.ep_damage_ratio, 0);
const cd = num(bb.aoe_cd, 2);
const epScale = num(tb.ep_damage_scale, 1);
// 损伤屏障 of S1: absorbs the gauge gain an element hit would add (after her element-taken multipliers)
const cut = Math.max(0, Math.min(1, num(t0.damage_resistance)));
// 损伤屏障 of S1: absorbs the gauge gain an element hit would add (after 神河谕使)
const installBarrier = (battle, unit) => {
battle.on('elementHit', (ctx) => {
if (ctx.target !== unit || !(unit.mem.philaeBarrier > 0) || !unit.skill?.active) return;
const dmg = ctx.dmg;
const f = num(dmg.mul, 1) * unit.s.elemTakenMul * (1 - Math.max(0, Math.min(1, num(unit.def?.epDamageResistance, 0))));
const eff = num(dmg.amount) * f;
const eff = num(dmg.amount) * num(dmg.mul, 1);
if (!(eff > 0)) return;
const take = Math.min(unit.mem.philaeBarrier, eff);
unit.mem.philaeBarrier -= take;
@@ -648,36 +661,47 @@ const KITS = {
}),
}),
talents: [{ install(battle, unit) {
battle.addBuff(unit, { key: 'talent:philae_elem', mods: { elemTakenMul: 1 - num(t0.damage_resistance) }, persist: true, allowDead: true });
// 神河谕使 (every skill): 元素损伤 taken ×(1 − damage_resistance); "受到凋亡损伤时回复2点技力" — even for 0 damage
battle.on('elementHit', (ctx) => {
const dmg = ctx.dmg;
if (ctx.target !== unit || !dmg || dmg.type !== 'element') return;
if (dmg.element === 'apoptosis' && !unit.skill?.active) giveSp(unit, num(t0.sp), 'talent');
if (cut > 0) dmg.mul *= 1 - cut;
}, { owner: unit, priority: 20 });
} }],
install(battle, unit) {
if (sel === S1) installBarrier(battle, unit);
// module PRP-X: her element damage ×ep_damage_scale while she blocks
if (epScale > 1) {
battle.on('elementHit', (ctx) => {
if (ctx.source === unit && ctx.dmg?.type === 'element' && ctx.target?.side === 'enemy' && unit.blocking.length) ctx.dmg.mul *= epScale;
}, { owner: unit });
}
battle.on('damaged', (ctx) => {
if (ctx.target !== unit || !unit.alive) return;
const sk = unit.skill;
if (ctx.type === 'element') {
// 神河谕使 (every skill): "受到凋亡损伤时回复2点技力"
if (ctx.dmg?.element === 'apoptosis' && !(sk && sk.active)) giveSp(unit, num(t0.sp), 'talent');
if (isS2 && sk && sk.active && !unit.findBuff('skill:philae_rage')) battle.addBuff(unit, { key: 'skill:philae_rage', mods: { atkPct: num(bb.atk) }, visible: true });
return;
}
if (!isS2 || !sk || !sk.active || !ctx.dmg?.isAttack || !ctx.source || ctx.source.side !== 'enemy') return;
if (battle.time < (unit.mem.philaeCd ?? -Infinity)) return;
unit.mem.philaeCd = battle.time + cd;
const epMul = unit.blocking.length ? epScale : 1;
for (const e of battle.enemiesInRadius(unit.x, unit.y, 1.5)) {
// "周围的地面敌人" = range x-4, the 3×3 tiles around her (PRTS 备注)
for (const e of battle.unitsInGrid(unit, NINE, { side: 'enemy' })) {
if (e.isFlying) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', canDodge: false, isSkill: true, tags: ['skill', 'counter'] });
if (ep > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'apoptosis', amount: unit.s.atk * ep * epMul, tags: ['skill'] });
if (ep > 0 && e.alive) battle.dealDamage(unit, e, { type: 'element', element: 'apoptosis', amount: unit.s.atk * ep, tags: ['skill'] });
}
fx(battle, 'aoe', unit, { radius: 1.5, dmgType: 'arts', skill: 'philae_2' });
fx(battle, 'aoe', unit, { radius: 1.5, tiles: 'box', dmgType: 'arts', skill: 'philae_2' }); // box: the 3×3 tiles
}, { owner: unit });
},
};
},
// ---- 3_07 见行者 · 推击手 (hidden) — S2 惊爆射击: push every enemy in the skill range forward + stun (longer when
// slammed into a wall, collided enemies stunned too); 技巧射击: ignore DEF vs heavy enemies;
// slammed into a wall, collided enemies stunned too; air units too [ASSUMED: "范围内所有敌人", no 对空 note on
// PRTS] — a 失衡免疫 enemy is not pushed but still stunned); 技巧射击: ignore DEF vs heavy enemies;
// 精锐 module PUS-X: redeployed on a ranged tile ⇒ half the deployment cost back
chess_char_3_07_a: (bb, chess, def) => {
const d = defOf(chess, def);
@@ -693,7 +717,7 @@ const KITS = {
kind: 'instant',
onStart({ battle, unit }) {
const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit));
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && keys.has(tileKeyOf(e)));
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys));
const pushed = new Set(victims);
const dist = 0.5 + 0.5 * force; // engine displacement convention (docs/SIM.md §12)
for (const e of victims) {
@@ -844,7 +868,7 @@ const KITS = {
battle.on('kill', (ctx) => {
const v = ctx.victim;
if (!unit.skill?.active || !alive(unit) || v.side !== 'enemy' || !isNormalEnemy(v)) return;
if (!unit.rangeKeySet?.has(tileKeyOf(v))) return;
if (!unit.rangeKeySet || !onTiles(v, unit.rangeKeySet)) return;
unit.mem.hainiMul = Math.min(upMax, (unit.mem.hainiMul ?? 1) + up);
}, { owner: unit });
} }],
@@ -971,8 +995,9 @@ const KITS = {
// 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale);
// 精锐 module GUA-Y: damage taken −15 %
// S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile to sleep (for the
// skill's duration: no own value in the data) and heals every ally of the skill range by ATK × ratio each second;
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she
// blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the
// skill range by ATK × ratio each second;
// 精锐 module GUA-X: her heals on allies under hp_ratio HP × heal_scale
chess_char_3_12_a: (bb, chess, def) => {
const d = defOf(chess, def);
@@ -984,7 +1009,7 @@ const KITS = {
const mercy = num(t1.atk_scale, 1);
const sleepersInRange = (battle, unit) => {
const set = unit.rangeKeySet;
return set ? battle.enemies.filter((e) => e.alive && !e.hidden && e.s.flags.sleep && !e.s.flags.untargetable && !e.isFlying && set.has(tileKeyOf(e))) : [];
return set ? battle.enemies.filter((e) => e.alive && !e.hidden && e.s.flags.sleep && !e.s.flags.untargetable && !e.isFlying && onTiles(e, set)) : [];
};
const kit = {
trait: { hitSleep: true }, // 仁慈: 自身可以攻击…沉睡的目标 (沉睡 = 无敌 for everyone else)
@@ -1027,8 +1052,11 @@ const KITS = {
onStart({ battle, unit, skill }) {
const dur = skill.timeLeft > 0 ? skill.timeLeft : Math.max(0.1, num(s.duration, 10));
let n = 0;
// PRTS 备注: "技能生效对象实际为“自身这格内的所有地面敌人及自身阻挡的敌人”,即使阻挡的是飞行敌人" — a blocked
// enemy stands at the block radius, outside her tile (Battle._checkBlock)
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || Math.round(e.x) !== unit.tileC || Math.round(e.y) !== unit.tileR) continue;
if (!e.alive || e.hidden) continue;
if (e.blockedBy !== unit && (e.isFlying || !bodyOnTile(e, unit.tileR, unit.tileC))) continue;
if (battle.applyStatus(e, 'sleep', { duration: dur, source: unit })) n++;
}
fx(battle, 'aoe', unit, { radius: 0.5, skill: 'blemsh_2', status: 'sleep', n });
@@ -1180,7 +1208,7 @@ const KITS = {
const r = (k / COLS) | 0, c = k % COLS;
if (!freeTile(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
let s = 0;
for (const e of foes) if (Math.abs(Math.round(e.y) - r) <= reach && Math.abs(Math.round(e.x) - c) <= reach) s++;
for (const e of foes) if (bodyTileReach(e, r, c) <= reach) s++;
const dd = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
if (s > bs || (s === bs && dd < bd)) { best = [r, c]; bs = s; bd = dd; }
}
@@ -1301,7 +1329,7 @@ const KITS = {
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
} },
}),
skchr_vulpis_2: (s) => {
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空")
const grid = copyGrid(s.rangeGrid);
const n = Math.max(1, Math.floor(num(s.bb.max_target, 6)));
return {
+14 -11
View File
@@ -26,6 +26,7 @@
// `node tools/kit-coverage.mjs --tier 4 --strict`; tests: test/content/kits_alt_t4.test.js.
import { COLS } from '../../constants.js';
import { bodyDist, bodyInKeys } from '../../body.js';
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../targeting.js';
import { normalizeChess } from '../../simdata.js';
import { aggregateMods } from '../../buffs.js';
@@ -74,7 +75,7 @@ function ownTokenVariant(battle, tokenId, owner) {
function enemiesOnRange(battle, unit, keys = null) {
const set = keys || keySet(unit);
const out = [];
for (const e of battle.enemies) if (e.alive && !e.hidden && set.has(tileKey(e))) out.push(e);
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, set)) out.push(e);
return out;
}
/** Targetable enemies in the unit's current range (flyers included), best targets first. */
@@ -510,7 +511,7 @@ const kits = {
for (const e of battle.aliveEnemies()) {
if (!canTargetEnemy(unit, e, { canHitFly: true })) continue;
const t = L2 > 1e-9 ? Math.max(0, Math.min(1, ((e.x - ax) * (bx - ax) + (e.y - ay) * (by - ay)) / L2)) : 0;
const d = Math.hypot(e.x - (ax + t * (bx - ax)), e.y - (ay + t * (by - ay)));
const d = bodyDist(e, ax + t * (bx - ax), ay + t * (by - ay));
if (d <= w + 1e-9) hits.push({ e, t, d });
}
hits.sort((a, b) => a.t - b.t || a.d - b.d || a.e.id - b.e.id);
@@ -880,7 +881,7 @@ const kits = {
if (!e.alive) { floating.delete(id); continue; }
if (e.findBuff('levitate')) continue;
floating.delete(id);
if (S2 && unit.alive && unit.deployed && skillActive(unit) && keySet(unit).has(tileKey(e))) {
if (S2 && unit.alive && unit.deployed && skillActive(unit) && bodyInKeys(e, keySet(unit))) {
battle.dealDamage(unit, e, { amount: unit.s.atk * land, type: 'arts', isSkill: true, tags: ['skill', 'landing'] });
battle.fx('splash', { x: e.x, y: e.y, id: e.id });
}
@@ -1041,7 +1042,7 @@ const kits = {
skill: {
kind: 'duration',
onStart({ battle, unit, skill }) {
const foes = targetsInRange(battle, unit).sort((a, b) => Math.hypot(b.x - unit.x, b.y - unit.y) - Math.hypot(a.x - unit.x, a.y - unit.y) || a.id - b.id);
const foes = targetsInRange(battle, unit).sort((a, b) => bodyDist(b, unit.x, unit.y) - bodyDist(a, unit.x, unit.y) || a.id - b.id);
const t = foes[0];
unit.mem.tornado = t ? { x: t.x, y: t.y, acc: 0 } : null;
if (!t) return;
@@ -1254,6 +1255,7 @@ const kits = {
// S1 细雨无声 (passive, for the skill duration: ATK up; hits silence the target 5 s/8 s — 失去特殊能力 — with
// 260/320 arts per s meanwhile); S2 阵雨连绵 (passive: deploy burst 150 %/180 % arts + RES −15 %/−20 % around her;
// for the skill duration ATK up and attacks become arts double hits). Talent 德克萨斯传统 applies to every passive.
// The S3 rain hits air units (PRTS 备注 "效果可对空"); so does the S2 deploy burst [ASSUMED: no note].
chess_char_4_16_a: (bb, chess, def) => {
const t0 = tbb(def, 0), t1 = tbb(def, 1);
const tb = def.traitBb || {};
@@ -1375,8 +1377,9 @@ const kits = {
skill: { kind: 'passive', mods: { defPct: num(bb.def) } },
talents: [
{ install(battle, unit) { // 战术装甲: 25 % 伤害抵挡 — negates an enemy phys/arts damage instance ("抵挡一次物理或法术伤害")
battle.on('hit', (c) => {
if (c.target !== unit || !c.source || c.source.side !== 'enemy' || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return;
battle.on('hit', (c) => { // 抵挡 is target-side: a 无来源 burst (source null) counts via the enemy credited with it
const src = c.source || c.credit;
if (c.target !== unit || !src || src.side !== 'enemy' || c.dmg.cancel || (c.dmg.type !== 'phys' && c.dmg.type !== 'arts')) return;
if (battle.rng.chance(num(t0.prob, 0.25))) { c.dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); }
}, { owner: unit });
} },
@@ -1492,8 +1495,8 @@ const kits = {
// ===== 焰尾 (pioneer) S3 焰心 — 8 DP over the skill, faster, +ATK, block +1, 60 % dodge; talents 前锋剑术 / 红松骑士团团长
// S1 迅敏直觉 (+6 DP, dodges the next physical attack); S2 “红松林” (+11/12 DP; ≤ 6 enemies around: 2 × 180 %/210 %
// phys + 0.5 s stun; allies around +40 %/45 % physical dodge for 10 s); module SOL-X (她们的未来): ATK/DEF +8 %
// while blocking. Her dodges (any source) feed talent 前锋剑术.
// phys + 0.5 s stun, air units too — PRTS 备注 "※可对空"; allies around +40 %/45 % physical dodge for 10 s);
// module SOL-X (她们的未来): ATK/DEF +8 % while blocking. Her dodges (any source) feed talent 前锋剑术.
chess_char_4_19_a: (bb, chess, def) => {
const t1 = tbb(def, 1);
const tb = def.traitBb || {};
@@ -1646,7 +1649,7 @@ const kits = {
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !skillActive(unit) || !unit.extraRangeKeys) return;
const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend));
const ok = (e) => own.has(tileKey(e)) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
if (c.targets.every(ok)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter(ok);
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
@@ -1663,7 +1666,7 @@ const kits = {
const ds = num(tb.damage_scale, 0), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
battle.on('hit', (c) => {
if (c.source !== unit || !c.dmg.isAttack) return;
if (S3 && skillActive(unit) && unit.baseRangeKeys && !unit.baseRangeKeys.includes(tileKey(c.target))) c.dmg.mul *= num(bb.damage_scale, 1.25);
if (S3 && skillActive(unit) && unit.baseRangeKeys && !bodyInKeys(c.target, unit.baseRangeKeys)) c.dmg.mul *= num(bb.damage_scale, 1.25);
if (ds > 0) { const d = Math.hypot(c.target.x - unit.x, c.target.y - unit.y); c.dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo))); }
}, { owner: unit });
},
@@ -2064,7 +2067,7 @@ const kits = {
talents: [{ install(battle, unit) { // 血液样本回收: an enemy falls in range ⇒ +2 SP to her and a random ally in range
battle.on('death', (c) => {
const e = c.unit;
if (c.reason !== 'killed' || e.side !== 'enemy' || !unit.alive || !unit.deployed || !keySet(unit).has(tileKey(e))) return;
if (c.reason !== 'killed' || e.side !== 'enemy' || !unit.alive || !unit.deployed || !bodyInKeys(e, keySet(unit))) return;
unit.skill?.gainSp(num(t0['bldsk_t_1[self].sp'], 2), 'talent');
// SP cannot be gained while a timed skill runs (engine gainSp ignores it): such allies are not picked
const pick = battle.rng.pick(battle.alliesInGrid(unit).filter((a) => a !== unit && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive' && !(a.skill.active && a.skill.isTimed)));
+39 -24
View File
@@ -8,8 +8,8 @@
// Conventions shared by the kits of this file:
// - `base_attack_time` in skill blackboards is an absolute delta in seconds (AK attribute ADDITION: 烛煌 1.6 s − 1.3 s,
// 号角 2.8 s − 1.2 s, 白面鸮 2.85 s − 1.8 s, 寒檀 2.9 s − 2.4 s); it is converted into `batPct` of the chess's base BAT.
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge, × elemTakenMul),
// with `element` set for the client colour.
// - 元素伤害 (elemental HP damage, not a gauge) is the engine DamageInfo type 'elemental' (no DEF/RES/dodge,
// × elementalTakenMul = 元素脆弱), with `element` set for the client colour.
// - Mechanics missing from the chess text follow the PRTS 备注 of the base operator (verified 2026-09-28): 号角 S3 overload
// is the second half of the 24 s duration; 圣约送葬人's extra attack consumes no ammo; 夕 S1 splash 1.7; 烛煌 revive stun
// radius 1.7; 寒檀 icicles splash 1.5 and cycle left row → right row → own row; 失重 = weight −1 level; 魔王 motes orbit
@@ -29,9 +29,10 @@
// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'.
import { COLS } from '../../constants.js';
import { bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../targeting.js';
import { frontOf, rotateOffset, toLocal } from '../../dir.js';
import { mitigate } from '../../damage.js';
import { mitigate, hasHp } from '../../damage.js';
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 山 module "生命值高于50%时". */
@@ -77,8 +78,8 @@ const HORN_S2_OVERLOAD_AT = 0.5;
// ---- helpers ------------------------------------------------------------------------------------------------------
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const on = (u) => !!u && u.alive && u.deployed && !u.removed;
const tileKey = (u) => Math.round(u.y) * COLS + Math.round(u.x);
const inRange = (unit, x) => !!unit.rangeKeySet && unit.rangeKeySet.has(tileKey(x));
/** In the unit's current range: an ally by its tile, an enemy by its body (a huge one's every tile — sim/body.js). */
const inRange = (unit, x) => !!unit.rangeKeySet && bodyInKeys(x, unit.rangeKeySet);
const talent = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i)?.bb ?? {};
const talentRec = (chess, i) => (chess?.talents || []).find((t) => t && t.index === i) ?? null;
const traitBb = (chess) => chess?.trait?.bb ?? {};
@@ -158,7 +159,7 @@ function meleeAttacker(e, t) {
/** Enemy not moving along its route (blocked, stunned, rooted, waiting…). */
const isStill = (e) => !!(e.blockedBy || !e.moving || e.s.flags.stun || e.s.flags.noMove || !(e.s.moveSpeed > 0));
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elemTakenMul in the pipeline, never trueTakenMul). */
/** Elemental HP damage (元素伤害): engine type 'elemental' (× elementalTakenMul in the pipeline, never trueTakenMul). */
function elementHit(battle, src, tgt, amount, tag, element = null) {
if (!tgt || !tgt.alive || !(amount > 0)) return 0;
return battle.dealDamage(src, tgt, { amount, type: 'elemental', element, canDodge: false, tags: ['element', tag] });
@@ -264,7 +265,7 @@ function enemiesInGrid(battle, unit, grid, n = 0, { ignoreStealth = false } = {}
let list;
if (ignoreStealth) {
const set = new Set(keys);
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && set.has(Math.round(e.y) * COLS + Math.round(e.x)));
list = battle.enemies.filter((e) => e.alive && !e.hidden && e.deployed && !e.s.flags.untargetable && bodyInKeys(e, set));
} else list = battle.enemiesInKeys(keys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, list, null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
@@ -591,7 +592,7 @@ const KITS = {
const dmgTick = unit.mem.blazeAcc2 >= 1 - 1e-9;
if (dmgTick) unit.mem.blazeAcc2 -= 1;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying || !tiles.has(Math.round(e.y) * COLS + Math.round(e.x))) continue;
if (!e.alive || e.hidden || e.isFlying || !bodyInKeys(e, tiles)) continue;
battle.addBuff(e, { key: `blaze2:ground:${unit.id}`, duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + num(bb.move_speed)) } });
if (!dmgTick) continue;
const d = battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'blazeGround'] });
@@ -759,7 +760,9 @@ const KITS = {
// T1 本性的坚守: heal 100 (160 below 50 %) on every hit taken. T2 血脉的哺养: per kill +120 max HP / +30 ATK (×9),
// other Abyssal Hunters +50 %. Module (elite): healing received ×1.2.
// S1 必须促成的接触 (instant): the anchor lands on the best enemy of the skill range (beyond his own range, unblocked
// first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each.
// first) and drags up to max_target enemies around it (RING1) in front of him (中等力度), atk_scale × ATK phys each;
// a 捕网 — ground enemies only ([ASSUMED] like 雪雉's "不对空" net). S3's anchor blast has no such note: it hits air
// units too [ASSUMED].
// S2 必须维系的界限 (toggle, 持续时间无限): T1 ×talent_scale, block +1, max HP +, ATK +.
chess_char_5_05_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
@@ -778,9 +781,11 @@ const KITS = {
// unblocked ground enemies, then the usual target order
const list = enemiesInGrid(battle, unit, skillGrid(chess, def) ?? unit.rangeGrid);
const reach = (e) => inRange(unit, e);
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying) ?? list[0];
// the anchor is a 捕网 (PRTS 备注 "实际效果为捕网而非拖拽"); 雪雉's 捕网 is "不对空" (PRTS 雪雉 备注) — [ASSUMED] the same
// for this one: it never lands on or catches air units (FLY, 近地悬浮, 浮空)
const main = list.find((e) => !reach(e) && !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.blockedBy && !e.isFlying) ?? list.find((e) => !e.isFlying);
if (!main) return;
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.s.flags.untargetable && !e.s.flags.sleep)
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
.slice(0, Math.max(1, num(bb.max_target, 2)));
const fr = unit.tileR + unit.fwd[0], fc = unit.tileC + unit.fwd[1];
@@ -874,7 +879,8 @@ const KITS = {
// max HP and take −10 % phys damage afterwards. Reaper trait (heal per hit, module 60) comes from the profession.
// S1 玫影觅迹 (instant, attack SP): next attack atk_scale × ATK, twice. S2 绯红壁合 (duration): no attacks; blood sickles
// on herself and on one other ground unit (the ally with the most enemies around it) cut every enemy around them (RING1)
// for atk_scale × ATK phys every `interval` s. Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
// for atk_scale × ATK phys every `interval` s — ground enemies only unless the carrier has taken off (PRTS 备注).
// Module REA-Y (elite): ASPD +12 with ≥ 2 enemies in range.
chess_char_5_06_a: (bb, chess, def) => {
const t0 = talent(chess, 0), t1 = talent(chess, 1), tb = traitBb(chess);
const hpScale = num(bb['attack@max_hp_scale'], 0.6), defScale = num(bb['attack@def_scale'], 1), resScale = num(bb['attack@magic_resistance_scale'], 1);
@@ -900,7 +906,11 @@ const KITS = {
unit.mem.sickleAcc -= iv;
for (const a of unit.mem.sickles || []) {
if (!a.alive || !a.deployed) continue;
// PRTS 备注 "被添加血镰的单位处于起飞时,血镰可对空": a sickle on the ground spares air units (FLY, 近地悬浮, 浮空);
// 起飞 = an airborne skywalker (蒂比's skill: off the ground, flag blockFly)
const air = !a.ground && !!a.s.flags.blockFly;
for (const e of battle.enemiesInRadius(a.x, a.y, RING1)) {
if (e.isFlying && !air) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'bloodSickle'] });
}
}
@@ -1224,7 +1234,7 @@ const KITS = {
unit.mem.flares = fl.filter((f) => f.until > battle.time + 1e-9);
const r = num(bb.projectile_range, 1.7);
for (const f of unit.mem.flares) for (const e of battle.enemies) {
if (e.alive && !e.hidden && Math.hypot(e.x - f.x, e.y - f.y) <= r + 1e-9) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
if (e.alive && !e.hidden && bodyInRadius(e, f.x, f.y, r)) battle.applyStatus(e, 'reveal', { duration: AURA_DUR, source: unit });
}
}, { owner: unit });
}
@@ -1616,7 +1626,8 @@ const KITS = {
// ---------------------------------------------------------------------------------------------------------------
// 归溟幽灵鲨 — dollkeeper. S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1; afterwards she counts as knocked out
// (→ substitute, or death if already one). T1 拥抱自我: the substitute slows nearby enemies −40 % and deals 40 % ATK
// arts/s to them. T2 阿戈尔的深邃: Abyssal Hunters in the team max HP +20 %. Module (elite): substitute ATK +15 %.
// arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only). T2 阿戈尔的深邃: Abyssal Hunters in the team
// max HP +20 %. Module (elite): substitute ATK +15 %.
// S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio,
// ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio
// is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP.
@@ -1667,11 +1678,11 @@ const KITS = {
const slow = num(t0.move_speed), scale = num(t0.atk_scale);
whileOn(battle, unit, AURA_IV, () => {
if (!unit.trait.doll || !slow) return;
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
});
whileOn(battle, unit, 1, () => {
if (!unit.trait.doll || !(scale > 0)) return;
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
});
} },
{ install(battle, unit) { // 阿戈尔的深邃
@@ -1704,9 +1715,10 @@ const KITS = {
},
// ---------------------------------------------------------------------------------------------------------------
// 凛御银灰 — S2 御敌的锋锐 (2 charges): the 6 front-most enemies in the skill range take 260 % ATK phys, cold + reveal
// for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right side guard/caster/
// sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when they redeploy (≤2).
// 凛御银灰 — S2 御敌的锋锐 (2 charges; PRTS 备注 "可对空"): the 6 front-most enemies in the skill range take 260 % ATK
// phys, cold + reveal for 4 s; the waiting (knocked-out) op nearest to 风雪之眼 by cost gets −11 redeploy cost (right
// side guard/caster/sniper first); waiting ops left of the eye (cost < eye cost) cast the AoE with their own ATK when
// they redeploy (≤2).
// T1 开放性开局: ops left of the eye and himself: +4 SP at deployment, redeploy time −20 %.
// T2 雪境先驱: Kjerag ops freeze-immune, DEF +60, 1.5 % max HP/s regen; doubled after 15 s on the field.
// The eye follows the selected skill (its overrideTokenKey: S1 eagle1 cost 16, S2 eagle2 14, S3 eagle3 19).
@@ -1846,13 +1858,15 @@ const KITS = {
const tok = battle.tokenDef(eyeId, unit); // the owner's skill variant (DESIGN §16)
unit.mem.eyeCost = num(tok?.stats?.cost, 14);
if (selectedId(chess, def) === 'skchr_svash2_3') {
// S3: every attack hits the enemies of the skill range on the target's line (his facing frame)
// S3: every attack hits the enemies of the skill range on the target's line (his facing frame): the line of
// the target's position; another enemy is on it when its body is (a huge one: any tile — body.js)
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.active || !c.targets.length) return;
const main = c.targets[0];
const lat = (e) => toLocal(Math.round(e.y) - unit.tileR, Math.round(e.x) - unit.tileC, unit.dir)[0];
const l0 = lat(main);
const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && lat(e) === l0);
const lat = (r, cc) => toLocal(r - unit.tileR, cc - unit.tileC, unit.dir)[0];
const l0 = lat(Math.round(main.y), Math.round(main.x));
const onLine = (e) => bodyKeys(e).some((k) => lat(Math.floor(k / COLS), k % COLS) === l0);
const line = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).filter((e) => e !== main && onLine(e));
if (line.length) c.targets = [main, ...line];
}, { owner: unit, priority: 10 });
}
@@ -2424,7 +2438,8 @@ const KITS = {
if (c.source !== unit || c.type === 'element' || c.target.side !== 'enemy' || !c.target.alive || !c.dmg) return;
// (S1 has no attack override: its attacks are the skill's while it runs)
const skillAtk = c.dmg.isSkill || (sid === 'skchr_nymph_1' && !!unit.skill?.active);
if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0) {
// (not on a killing blow: the target is at 0 HP here — no burst on the corpse)
if (skillAtk && c.dmg.isAttack && ep > 0 && c.amount > 0 && hasHp(c.target)) {
battle.dealDamage(unit, c.target, { type: 'element', element: 'apoptosis', amount: c.amount * ep, tags: ['skill', 'nymph'] });
}
if (c.dmg.isAttack && c.target.alive) soul(battle, unit, c.target, c.dmg.isSkill ? Math.max(skillSoul, soulBase) : soulBase);
+86 -44
View File
@@ -10,7 +10,7 @@
// reinforcement …) are kept and built upon.
//
// Simplifications (one line per id; see also the report of the content phase):
// 1_15 盟约·辅助干员 element pick = neural → burn → apoptosis, skipping the elements already bursting on the target.
// 1_15 盟约·辅助干员 every damage she deals attaches all three elements, neural → burn → apoptosis (see pithst).
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
@@ -102,6 +102,8 @@ import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targe
import { aggregateMods } from '../../buffs.js';
import { COLS, ROWS } from '../../constants.js';
import { rotateOffset } from '../../dir.js';
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
import { hasHp } from '../../damage.js';
import { summonToken, TOKEN_IDS } from '../tokens.js';
// ------------------------------------------------------------------------------------------------------------------
@@ -279,8 +281,9 @@ function onElementHit(battle, unit, fn) {
}, { owner: unit });
}
/** Deal `amount` of element `el` (元素损伤) — nothing on a target at 0 HP (a lethal hit's `damaged` hook, see hasHp). */
const elementDmg = (battle, src, tgt, el, amount, tags = ['skill']) =>
(amount > 0 && tgt && tgt.alive ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
(amount > 0 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
/**
* 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK after the target's own multipliers (flat
@@ -325,23 +328,44 @@ function aura(battle, unit, period, fn) {
// ------------------------------------------------------------------------------------------------------------------
// 盟约·辅助干员 chess_char_1_15 (巫役) — S1 战术咏唱·双型; 迭代元素
/** 迭代元素's elements in the order they are applied: 神经损伤 "(优先)", then 灼燃损伤, then 凋亡损伤. */
export const PITHST_ELEMENTS = Object.freeze(['neural', 'burn', 'apoptosis']);
/**
* 迭代元素 "攻击同时附带18%攻击力的神经损伤(优先)、灼燃损伤、凋亡损伤;优先攻击未处于损伤爆发的目标":
* * timing = every damage she deals (PRTS 盟约·辅助干员 corrects "攻击同时" to "造成伤害时"): HP damage > 0 — a dodged
* or fully absorbed hit deals none; her own element fills and the bursts they cause never re-trigger it; a killing
* blow attaches nothing (the target is dead — no burst on the corpse);
* * each such damage attaches ep_damage_ratio × ATK of ALL THREE elements, 神经 first, then 灼燃, then 凋亡 (user
* playtest #5 #3: the kit used to pick one element "not bursting" — but a burst's 爆发冷却 locks every gauge of the
* unit, so it only ever filled 神经). PRTS 元素: when one unit applies several elements that would each fill their
* gauge, the element applied first bursts — from her alone 神经 bursts (the "(优先)"), while her 灼燃 / 凋亡 add to
* the gauges the team builds (余, 塑心 …). [ASSUMED: simultaneous application — the reading of the official text]
* * elite module RIT-X "对精英和领袖敌人造成的元素损伤提升18%" = ep_damage_ratio_boss (0.2124) vs ELITE / BOSS enemies:
* a talent ratio, not an "元素损伤提升" (PRTS 备注), so it changes nothing else she deals (e.g. a 灼燃维式重锤).
* The target pick prefers enemies not in a burst (`priority: 'notBurst'`).
*/
function pithst(bb, chess, def) {
const t0 = tbb(def, 0);
const ratio = num(t0.ep_damage_ratio);
const ratioBoss = num(t0.ep_damage_ratio_boss, ratio); // elite module: ×1.18 vs elite/leader enemies
const ratioBoss = num(t0.ep_damage_ratio_boss, ratio);
return {
skill: {
kind: 'duration',
mods: { aspd: num(bb.attack_speed) },
targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) },
},
trait: {
priority: 'notBurst',
afterHit(battle, unit, target) {
if (!target || !target.alive || !(ratio > 0)) return;
const el = ['neural', 'burn', 'apoptosis'].find((k) => !target.findBuff(k + 'Burst'));
if (el) elementDmg(battle, unit, target, el, unit.s.atk * (isElite(target) ? ratioBoss : ratio), ['talent']);
},
trait: { priority: 'notBurst' },
install(battle, unit) {
if (!(ratio > 0)) return;
battle.on('damaged', (ctx) => {
const t = ctx.target, d = ctx.dmg;
// hasHp: a killing blow attaches nothing (the hook runs before battle.kill, see hasHp)
if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t) || !(ctx.amount > 0)) return;
if (ctx.type === 'element' || ctx.type === 'elemental' || (d && d.tags && d.tags.includes('burst'))) return;
const amount = unit.s.atk * (isElite(t) ? ratioBoss : ratio);
for (const el of PITHST_ELEMENTS) elementDmg(battle, unit, t, el, amount, ['talent', 'pithst']);
}, { owner: unit });
},
};
}
@@ -370,9 +394,8 @@ function ensureWanted(battle) {
const lat = battle.allyUnits.filter((a) => live(a) && a.kind === 'op' && hasBond(a, 'lateranoShip'));
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !isElite(e) || e.findBuff('lemuen:wanted')) continue;
const k = keyOf(e);
// "停留超过8秒": a continuous stay — leaving every 拉特兰 range restarts the count
if (!lat.some((a) => a.rangeKeySet && a.rangeKeySet.has(k))) { W.time.delete(e); continue; }
if (!lat.some((a) => a.rangeKeySet && bodyInKeys(e, a.rangeKeySet))) { W.time.delete(e); continue; }
const t = (W.time.get(e) ?? 0) + 0.25;
W.time.set(e, t);
if (t >= need - 1e-9) {
@@ -423,8 +446,8 @@ function lemuen(bb, chess, def) {
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
for (const e of battle.enemiesInRadius(x, y, d2)) {
if (e.s.flags.untargetable) continue;
const d = Math.hypot(e.x - x, e.y - y);
battle.dealDamage(unit, e, { amount: atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
const d = bodyDist(e, x, y);
battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
};
const bombard = (battle, unit, locks) => {
@@ -534,7 +557,7 @@ function lemuen(bb, chess, def) {
battle.on('tick', () => { // wanted targets' tiles join her range (engine extra range keys, kept across rebuilds)
if (!live(unit)) return;
const keys = [];
for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('lemuen:wanted')) keys.push(keyOf(e));
for (const e of battle.enemies) if (e.alive && !e.hidden && e.findBuff('lemuen:wanted')) keys.push(...bodyKeys(e));
const s = keys.join(',');
if (s === sig) return;
sig = s;
@@ -655,7 +678,7 @@ function sbell2(bb, chess, def) {
if (dotAcc + 1e-9 < 1) return;
dotAcc -= 1;
for (const e of battle.enemies) {
if (e.alive && !e.hidden && !e.isFlying && snow.has(keyOf(e))) battle.dealDamage(unit, e, { amount: unit.s.atk * S2.dot, type: 'arts', isSkill: true, tags: ['skill', 'snow'] });
if (e.alive && !e.hidden && !e.isFlying && bodyInKeys(e, snow)) battle.dealDamage(unit, e, { amount: unit.s.atk * S2.dot, type: 'arts', isSkill: true, tags: ['skill', 'snow'] });
}
}, { owner: unit });
} },
@@ -781,7 +804,8 @@ function yu(bb, chess, def) {
// ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below)
skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } },
// S2 厚礼上宾: atk_scale × ATK arts on every enemy of the skill range + the ground-reachable ones teleported onto his
// tile; block +block_cnt, HP / ATK +, normal attacks deal arts damage
// tile; block +block_cnt, HP / ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空
// note on PRTS]; the teleport takes ground units only ("地面可达目标")
skchr_yu_2: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) },
@@ -1064,7 +1088,7 @@ function pasngr(bb, chess, def) {
let best = null, bd = Infinity;
for (const x of battle.enemiesInRadius(prev.x, prev.y, ch.radius || 1.8)) {
if (hit.has(x.id) || !canTargetEnemy(unit, x, ANY)) continue;
const d = Math.hypot(x.x - prev.x, x.y - prev.y);
const d = bodyDist(x, prev.x, prev.y);
if (d < bd - 1e-9) { bd = d; best = x; }
}
prev = best;
@@ -1132,7 +1156,7 @@ function pasngr(bb, chess, def) {
const atk = num(t1.atk), spr = num(t1.sp_recovery_per_sec);
aura(battle, unit, 0.25, () => {
const near = new Set(absoluteRangeKeys(N4, unit.tileR, unit.tileC, 1, 0));
if (battle.enemies.some((e) => e.alive && !e.hidden && near.has(keyOf(e)))) return;
if (battle.enemies.some((e) => e.alive && !e.hidden && bodyInKeys(e, near))) return;
battle.addBuff(unit, { key: 'pasngr:lone', mods: { atkPct: atk, spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
});
} },
@@ -1472,7 +1496,7 @@ function reed2(bb, chess, def) {
battle.after(0, () => {
if (!live(unit)) return;
battle.fx('scorchBurst', { x, y, id: unit.id, r: aoeR });
for (const e of battle.enemiesInRadius(x, y, aoeR)) {
for (const e of battle.enemiesInRadius(x, y, aoeR, true)) { // splash around the victim: 中点判定
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * aoe, type: 'arts', isSkill: true, isSplash: true, tags: ['skill', 'scorch'] });
scorch(battle, unit, e);
@@ -1552,12 +1576,16 @@ function cello(bb, chess, def) {
install(battle, unit) {
if (sid !== 'skchr_cello_2') return;
const ratio = num(bb.ep_damage_ratio);
battle.on('damaged', (ctx) => {
const s = ctx.source, t = ctx.target;
if (!(ratio > 0) || !unit.skill?.active || !live(unit) || !s || !t || t.side !== 'enemy' || !t.alive || !(ctx.amount > 0)) return;
if (ctx.type === 'element' || ctx.type === 'elemental' || (s !== unit && s !== unit.mem.celloPartner)) return;
// PRTS 塑心 S2 备注: "受影响的干员即将造成伤害时,因该效果造成的凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源
// 始终为塑心" — a late `hit` handler (after every handler that may cancel the damage), so the 凋亡 lands while the
// target is alive and may burst before the damage; 无来源 bursts (source null) never trigger it. It rides the
// damage about to be dealt, so a hit dodged or absorbed afterwards still carried it [ASSUMED].
battle.on('hit', (ctx) => {
const s = ctx.source, t = ctx.target, d = ctx.dmg;
if (!(ratio > 0) || !unit.skill?.active || !live(unit) || !s || !t || t.side !== 'enemy' || !hasHp(t) || !d || d.cancel) return;
if (d.type === 'element' || d.type === 'elemental' || !(d.amount > 0) || (s !== unit && s !== unit.mem.celloPartner)) return;
elementDmg(battle, unit, t, 'apoptosis', unit.s.atk * ratio);
}, { owner: unit });
}, { owner: unit, priority: -1000 });
},
skill: {
kind: 'charges',
@@ -1591,10 +1619,9 @@ function cello(bb, chess, def) {
battle.on('elementHit', (ctx) => {
const t = ctx.target, d = ctx.dmg;
if (!d || d.type !== 'element' || d.element !== 'apoptosis' || !t || t.side !== 'enemy') return;
const k = keyOf(t);
let best = 1;
for (const c of S.cellos) {
if (!live(c) || !(c.mem.celloWide || c.rangeKeySet?.has(k))) continue;
if (!live(c) || !(c.mem.celloWide || bodyInKeys(t, c.rangeKeySet))) continue;
const v = scaled(c.mem.celloAmp, c);
if (v > best) best = v;
}
@@ -1636,7 +1663,7 @@ function nymph(bb, chess, def) {
if (cur) { cur.data.scale = Math.max(cur.data.scale, scale); cur.timeLeft = Math.max(cur.timeLeft, burst.timeLeft); return; }
battle.addBuff(t, {
key, duration: Math.max(0.1, burst.timeLeft), interval: 1, source: unit, data: { scale },
// 元素伤害 = the 'elemental' damage type (no DEF/RES, × elemTakenMul)
// 元素伤害 = the 'elemental' damage type (no DEF/RES, × elementalTakenMul)
onTick: ({ unit: e, buff }) => { if (e.findBuff('apoptosisBurst')) battle.dealDamage(unit, e, { amount: unit.s.atk * buff.data.scale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'elementDmg'] }); },
});
};
@@ -1669,7 +1696,7 @@ function nymph(bb, chess, def) {
battle.on('deploy', ({ unit: u }) => { if (u === unit) unit.mem.nymphKey = 0; }, { owner: unit });
battle.on('elementBurst', ({ target, element }) => {
if (element !== 'apoptosis' || !live(unit) || !target || target.side !== 'enemy') return;
if (!fieldWide && !unit.rangeKeySet?.has(keyOf(target))) return;
if (!fieldWide && !bodyInKeys(target, unit.rangeKeySet)) return;
unit.mem.nymphKey = Math.min(max, (unit.mem.nymphKey || 0) + 1);
const n = unit.mem.nymphKey;
battle.addBuff(unit, { key: 'nymph:key', mods: { atkPct: atk * n, aspd: fieldWide && n >= max ? as : 0 } });
@@ -2205,7 +2232,7 @@ function angel2(bb, chess, def) {
sortEnemyTargets(battle, u, cands, null);
const c = cands[0];
battle.fx('airstrike', { x: c.x, y: c.y, id: unit.id, r: AIRSTRIKE_RADIUS });
for (const e of battle.enemiesInRadius(c.x, c.y, AIRSTRIKE_RADIUS)) {
for (const e of battle.enemiesInRadius(c.x, c.y, AIRSTRIKE_RADIUS, true)) { // splash around the target: 中点判定
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'phys', isSkill: true, isSplash: true, tags: ['talent', 'airstrike'] });
}
}, { owner: unit });
@@ -2374,7 +2401,7 @@ function qiubai(bb, chess, def) {
if (!unit.alive || unit.deploySeq !== seq) return;
const x = target.x, y = target.y; // (a fallen target keeps its last position)
battle.fx('aoe', { x, y, id: unit.id });
for (const e of battle.enemiesInRadius(x, y, 1.2)) {
for (const e of battle.enemiesInRadius(x, y, 1.2, true)) { // splash around the target: 中点判定
if (e.alive && !e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.aoe_scale, 1), type: 'arts', isSkill: true, isSplash: e !== target, tags: ['skill'] });
}
}, { owner: unit });
@@ -2463,7 +2490,7 @@ function halo2(bb, chess, def) {
let best = null, bd = Infinity;
for (const e of battle.enemiesInRadius(px, py, R)) {
if (e === prev || !canTargetEnemy(unit, e, ANY)) continue;
const d = Math.hypot(e.x - px, e.y - py);
const d = bodyDist(e, px, py);
if (d < bd - 1e-9) { bd = d; best = e; }
}
if (!best) break;
@@ -2487,7 +2514,7 @@ function halo2(bb, chess, def) {
if (!target) return;
const R = num(bb.ability_range_radius, 2), k = Math.max(0, Math.floor(num(bb.max_target, 2)));
const near = battle.enemiesInRadius(target.x, target.y, R).filter((e) => e !== target && canTargetEnemy(unit, e, ANY))
.sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y) || a.spawnSeq - b.spawnSeq)
.sort((a, b) => bodyDist(a, target.x, target.y) - bodyDist(b, target.x, target.y) || a.spawnSeq - b.spawnSeq)
.slice(0, k);
for (const e of near) {
battle.fx('link', { x: e.x, y: e.y, id: unit.id, ids: [target.id, e.id] });
@@ -2523,7 +2550,7 @@ function halo2(bb, chess, def) {
if (!isDef) return;
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active) return;
const valid = (e) => e && e.alive && canTargetEnemy(unit, e, ANY) && unit.rangeKeySet?.has(keyOf(e));
const valid = (e) => e && e.alive && canTargetEnemy(unit, e, ANY) && bodyInKeys(e, unit.rangeKeySet);
const keep = (unit.mem.haloLocks || []).filter(valid);
for (const e of ctx.targets) if (keep.length < n && !keep.includes(e)) keep.push(e);
ctx.targets = keep.slice(0, n);
@@ -2585,7 +2612,7 @@ function nearl2(bb, chess, def) {
const keys = new Set(absoluteRangeKeys(N4, r, c, 1, 0));
battle.fx('sunBurst', { x: c, y: r, id: unit.id });
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !keys.has(keyOf(e))) continue;
if (!e.alive || e.hidden || !bodyInKeys(e, keys)) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'true', isSkill: true, tags: ['skill'] });
if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
}
@@ -2673,7 +2700,7 @@ function nearl2(bb, chess, def) {
if (b && (b === unit || b === unit.mem.sun)) ctx.dmg.type = 'true';
}, { owner: unit });
} },
{ install(battle, unit) { // 不畏苦暗
{ install(battle, unit) { // 不畏苦暗 — the 4 tiles around her, air units too [ASSUMED: no 对空 note on PRTS, like “耀阳”]
const last = ensureDeployTracker(battle);
const sc = num(t0.atk_scale), stun = num(t0.stun);
const dawn = (times) => {
@@ -2803,7 +2830,7 @@ function whitw2(bb, chess, def) {
let best = null, bd = Infinity, bestFree = null, bfd = Infinity;
for (const e of battle.enemies) {
if (!ok(e)) continue;
const dist = Math.hypot(e.x - d.x, e.y - d.y);
const dist = bodyDist(e, d.x, d.y);
if (dist < bd - 1e-9) { bd = dist; best = e; }
if (!taken.has(e) && dist < bfd - 1e-9) { bfd = dist; bestFree = e; }
}
@@ -2814,7 +2841,7 @@ function whitw2(bb, chess, def) {
if (!d.t) continue;
const dx = d.t.x - d.x, dy = d.t.y - d.y, dist = Math.hypot(dx, dy), step = speed * dt;
if (dist <= step) { d.x = d.t.x; d.y = d.t.y; } else { d.x += (dx / dist) * step; d.y += (dy / dist) * step; }
if (d.locked !== d.t && Math.hypot(d.t.x - d.x, d.t.y - d.y) <= R) {
if (d.locked !== d.t && bodyInRadius(d.t, d.x, d.y, R)) {
d.locked = d.t;
if (fear > 0) battle.applyStatus(d.t, 'fear', { duration: fear, source: unit });
battle.fx('droneLock', { x: d.x, y: d.y, id: d.t.id, src: unit.id });
@@ -3088,29 +3115,44 @@ function agoat2(bb, chess, def) {
}, { owner: unit });
} },
{ install(battle, unit) { // 氤氲
// "普通治疗使目标每秒额外受到一次治疗量和元素损伤回复量为10%的增益治疗,持续6秒(最多叠加3层)" — PRTS 备注: "本天赋受
// 特性治疗倍率影响,使用缓存攻击力;叠加时,重置持续时间并更新缓存攻击力": a 10 % heal shaped like her normal one —
// HP heal_scale × ATK and 元素损伤 recovery heal_scale × the trait's ep_heal_ratio × ATK (0.5 / 0.6: 5 % / 6 %,
// it used to be the full 10 %) — per stack, with the ATK of the last application; a new stack refreshes them all.
const sc = num(t0.heal_scale), dur = num(t0.duration, 6), max = Math.max(1, Math.floor(num(t0.max_stack_cnt, 3)));
if (!(sc > 0)) return;
const epRatio = () => num(unit.profile?.heal?.elementHealRatio, num(tb.ep_heal_ratio, 0.5));
battle.on('heal', (ctx) => {
const t = ctx.target;
if (ctx.source !== unit || ctx.opts?.hot || ctx.opts?.aura || ctx.opts?.regen || ctx.opts?.skillHeal || !t) return;
battle.addBuff(t, {
key: 'agoat2:mist', duration: dur, refresh: 'independent', maxStacks: max, interval: 1, source: unit,
onTick: ({ unit: a }) => {
const amt = unit.s.atk * sc;
battle.reduceElement(a, amt);
// keyed per 纯烬: two of them (co-op) each keep their own stacks, ATK and heal source
const b = battle.addBuff(t, {
key: `agoat2:mist:${unit.id}`, duration: dur, refresh: 'stack', stacks: 1, maxStacks: max, interval: 1, source: unit, data: {},
onTick: ({ unit: a, buff }) => {
const amt = num(buff.data.atk, unit.s.atk) * sc * Math.max(1, buff.stacks);
battle.reduceElement(a, amt * epRatio());
if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { hot: true });
},
});
if (b) b.data.atk = unit.s.atk;
}, { owner: unit });
} },
{ install(battle, unit) { // 火山灰疗愈 (×talent_scale during S3)
// "受到的元素损伤降低" is a 元素损伤 multiplier applied on the element hit (PRTS 元素 "…后续可应用元素损伤倍率
// 提升/降低等效果"), not `elemTakenMul` (元素伤害 / 元素脆弱): the aura buff carries the cut, `elementHit` applies it
const hp = num(t1.max_hp), er = num(t1.ep_damage_resistance), mulS = num(bb.talent_scale, 1);
aura(battle, unit, 0.5, () => {
const f = unit.skill?.active ? mulS : 1;
for (const a of battle.alliesInGrid(unit)) {
battle.addBuff(a, { key: 'agoat2:ash', mods: { hpPct: hp * f, elemTakenMul: Math.max(0, 1 - er * f) }, duration: 0.75, refresh: 'replace' });
battle.addBuff(a, { key: 'agoat2:ash', mods: { hpPct: hp * f }, data: { epCut: Math.min(1, er * f) }, source: unit, duration: 0.75, refresh: 'replace' });
}
});
battle.on('elementHit', (ctx) => {
const d = ctx.dmg, b = ctx.target?.findBuff?.('agoat2:ash');
if (!b || b.source !== unit || !d || d.type !== 'element') return;
const cut = num(b.data?.epCut);
if (cut > 0) d.mul *= Math.max(0, 1 - cut);
}, { owner: unit, priority: 20 });
} },
],
};
+12 -2
View File
@@ -18,8 +18,11 @@
import { getData } from '../../../data.js';
import { COLS } from '../../constants.js';
import { frontOf, offsetTile } from '../../dir.js';
import { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach } from '../../body.js';
export { COLS };
/** Where an enemy can be hit (a huge enemy's whole hit rectangle, sim/body.js) — for every range test on enemies. */
export { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach };
// =====================================================================================================================
// numbers & data
@@ -249,10 +252,17 @@ export function rowMates(battle, u) {
for (const a of battle.allyUnits) if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.tileR === u.tileR) out.push(a);
return out;
}
/** Enemy / ally tile key used by range sets (`unit.rangeKeySet`). */
/** Tile key used by range sets (`unit.rangeKeySet`): an ally's tile, an enemy's position tile (range tests: onKeys). */
export const tileKey = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x));
/** Is `target` on a tile of `keys` (Set or array)? An ally: its tile; an enemy: its body (a huge one: every tile). */
export const onKeys = (target, keys) => {
if (!target || !keys) return false;
if (target.side !== 'ally') return bodyInKeys(target, keys);
const k = tileKey(target);
return keys instanceof Set ? keys.has(k) : keys.includes(k);
};
/** Is `target` inside `u`'s current attack range (grid ranges)? */
export const inRange = (u, target) => !!(u && target && u.rangeKeySet && u.rangeKeySet.has(tileKey(target)));
export const inRange = (u, target) => !!(u && target && u.rangeKeySet && onKeys(target, u.rangeKeySet));
// =====================================================================================================================
// battle: buffs, fx, per-battle state
+22 -11
View File
@@ -60,6 +60,8 @@
import { COLS, ROWS, MOVE_SCALE } from '../constants.js';
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
import { bodyInKeys, bodyOnTile } from '../body.js';
import { hasHp } from '../damage.js';
import { genericKit } from './generic.js';
import { normDir, localOrder } from '../dir.js';
@@ -169,20 +171,22 @@ function scheduleLifetime(battle, unit, seconds) {
battle.after(s, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
}
/** Enemies (visible) standing on any of the absolute tile keys. Auras/bursts hit stealthed enemies too. */
/** Visible enemies whose body is on any of the absolute tile keys (sim/body.js); auras / bursts hit stealthed ones. */
function enemiesOnKeys(battle, keys) {
const set = keys instanceof Set ? keys : new Set(keys);
const out = [];
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
const r = Math.round(e.y), c = Math.round(e.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) continue;
if (set.has(r * COLS + c)) out.push(e);
if (bodyInKeys(e, set)) out.push(e);
}
return out;
}
/** Appear burst: damage (optional) + status (optional) on the enemies of `grid` around the token. Returns victims. */
/**
* Appear burst: damage (optional) + status (optional) on the enemies of `grid` around the token. Returns victims. Air
* units (Unit.isFlying) are hit too **[ASSUMED]**: PRTS has no 对空 note on any of them — 沙之碑 (蜜蜡 S2), 迷迭香的战术装备
* ("对自身攻击范围内的敌人造成晕眩"), “耀阳”, 纸偶 (风丸 折纸生花) — so a stun here also drops a hovering 掠海漂移体.
*/
function burst(battle, unit, { grid, amount = 0, type = 'phys', stun = 0, hits = 1, source = unit, fx = 'summonBurst' }) {
const keys = absoluteRangeKeys(grid || GRID_3X3, unit.tileR, unit.tileC, unit.dir, 0);
const victims = enemiesOnKeys(battle, keys);
@@ -451,7 +455,10 @@ function seaborn(bb, raw, def) {
};
}
/** “耀阳” (耀骑士临光 S3): appear burst (true, owner ATK × atk_scale, stun), blocks 2, lasts while the owner's skill runs. */
/**
* “耀阳” (耀骑士临光 S3): appear burst (true, owner ATK × atk_scale, stun — air units too [ASSUMED: no 对空 note on PRTS
* “耀阳”]), blocks 2, lasts while the owner's skill runs.
*/
function radiantSword(bb, raw, def) {
const grid = def?.skill?.rangeGrid ?? GRID_PLUS;
// 精锐 owner's module (isToken part): "攻击被阻挡的敌人时攻击力提升至115%" — any blocked enemy
@@ -759,7 +766,9 @@ function champagne(bb) {
let hit = null;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying) continue;
if (Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5 && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
// touching its tile: a huge enemy on any tile of its body (body.js)
const on = e.hitArea ? bodyOnTile(e, unit.tileR, unit.tileC) : Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5;
if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
}
if (!hit) return;
const hits = battle.time - unit.deployedAt >= extraAfter - 1e-9 ? 2 : 1;
@@ -885,8 +894,8 @@ function yanyouFallbackDef(battle) {
* * normal attack: rangeRadius 2.0, arts, 3 targets ("可同时攻击3个目标", talent 1.attack@max_target);
* * every damage it deals adds burn = ATK × 2.ep_damage_ratio (20 %: "自身造成伤害时,附加攻击力20%的灼燃损伤"; the
* official 下半 notice: "祛恶之焰附带的灼燃损伤调整为基于炎佑的攻击力") — normal attacks AND flame ticks;
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take element damage
* × 2.damage_scale (1.2);
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take 元素伤害 (burst
* damage) × 2.damage_scale (1.2) — not the gauge fill (damage.js);
* * immune to element damage ("即将受到元素损伤前取消此元素损伤");
* * 祛恶之焰 [模式乙] (the 炎 bond's mode, Skill_2; cooldown / initCooldown 15, atk_scale 0.6, hit_duration 20,
* range_radius 1.0): "触发索敌和普通攻击相同:锁定1个目标持续施法,最多持续20秒,每秒对目标周围半径1.0范围内的所有
@@ -985,10 +994,12 @@ function yanyouKit(bb, raw) {
unit.base.blockCnt = 0;
const speed = moveSpeed * MOVE_SCALE;
onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); });
// every damage it deals adds burn (not the burn itself, not element bursts)
// every damage it deals adds burn (not the burn itself, not element bursts; not a killing blow — the hook runs
// before the kill, at 0 HP: no burst on the corpse)
if (epRatio > 0) {
battle.on('damaged', (ctx) => {
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !ctx.target.alive) return;
const t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t)) return;
if (ctx.type === 'element' || ctx.type === 'elemental' || !(ctx.amount > 0)) return;
battle.dealDamage(unit, ctx.target, { type: 'element', element: 'burn', amount: unit.s.atk * epRatio, tags: ['yanyou'] });
}, { owner: unit });
+84 -27
View File
@@ -2,33 +2,61 @@
//
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul × type-taken mul × dmg.mul
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000), reduced by the
// target's 损伤抵抗 (data `epResistance`, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)").
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A; Elemental (元素伤害): max(A×(1 − 元素抗性/100), 5 %·A)
// (PRTS 游戏数据基础 DMG_e; 元素抗性 = the target's data `epDamageResistance`: 0 on every enemy in data/enemies.json).
// Element damage ('element' type + element, 元素损伤) fills a gauge instead of HP (1000; enemy leaders 2000):
// gain = amount × dmg.mul × target elemTakenMul (元素损伤倍率: "受到的元素损伤提高/降低…") × max(5 %, 1 − 损伤抵抗/100)
// with 损伤抵抗 = the target's data `epResistance` (EP_RESISTANCE, a percentage: PRTS 元素 "受到的元素损伤 = 损伤值 ×
// (1 − 损伤抵抗 × 0.01),后续可应用元素损伤倍率"; the 5 % floor of DMG_e — PRTS 游戏数据基础 "目标受到元素损伤时也可以使用
// 该公式计算,只需要将 D 值改为目标的损伤抵抗即可"; in data/enemies.json only 转译基底·α has 10, every other enemy — 海嗣
// included — 0; newer event enemies of the full enemy_database go up to 15, operators are all 0). No
// source-side multiplier touches it (a kit scales its own amount), no gauge ever decays (EP_RECOVERY_PER_SEC 0), and
// 元素脆弱 (`elementalTakenMul`, "受到的元素伤害提升") never scales it — only 元素伤害.
// A full gauge bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural stun 10 s, then 1000 true · apoptosis 15 s: 阻回
// (noSp) + 静默 (no skill activation), −1 SP/s, 100 arts/s · erosion permanent DEF −100, then 800 phys (10 s).
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 3 麻痹, then 6000 元素伤害 (10 s) ·
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
// Burst damage is 无来源 (PRTS 元素 "元素爆发通常造成无来源的伤害"; PRTS 伤害分类: 无来源 = "无法被追溯伤害来源", yet "无来源
// 伤害的击杀也能追溯击杀来源"): DamageInfo `sourceless` — the damage-dealt multipliers and penetration of the unit that
// filled the gauge do not apply (the target's own RES cuts do, "可享受法抗减少效果"), and the `hit` / `damaged` / `fatal`
// hooks get `source: null`, so no content keyed on the attacker (ATK ×1.5 vs 海怪, 精准狙击镜, bond / module 伤害提升,
// reflect, "受到来自…的伤害") touches it; the hook ctx's `credit` and the kill / stats keep the filler ("元素爆发本身来源于
// 造成“爆条”的元素损伤的来源").
// 爆发冷却 (PRTS 元素, "阻回" after a burst): for the burst's duration (the `<el>Burst` buff) NO element of the unit can
// fill or be recovered (reduceElement) — the bursting gauge shows full — and when it ends EVERY gauge of the unit
// resets to 0. A burst that is still resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists) already
// counts as locked (`unit.burstPending[el]`), so a hook that spreads an element back (淤困 parasite hosts next to each
// other) cannot re-burst the unit recursively. The unit's shown gauge (the fullest, "当前损伤元素") and its cooldown
// travel in b.snap `elem` (elementView).
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES
// (元素抗性 instead), no dodge, × source dmgDealtMul × target elementalTakenMul (元素脆弱) only — the target's
// dmgTakenMul (脆弱 and the other "受到的伤害±" effects) does not scale it: ba.fragile is "受到的物理、法术、真实伤害提升"
// (gamedata_const termDescriptionDict), 元素伤害 has its own ba.elementfragile [ASSUMED for the damage_resistance-type
// cuts, which share the multiplier]. An element fill on a target with no HP left (hasHp: a lethal hit's `damaged` hook
// runs before battle.kill, while the target is still `alive` at 0 HP) is refused, so nothing bursts on a corpse.
// Element healing (reduceElement) lowers every element type by the amount, each on its own (PRTS 菲莱 备注: "清除元素损伤"
// = "一次等同于自身最大元素值的全类型元素损伤治疗"). Sleeping units (沉睡: 无敌) take no damage unless the attacker's profile
// has `hitSleep` or the damage carries `ignoreSleep`.
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
/**
* Alive AND still has HP (a boss: its pool's HP). A `damaged` hook sees a lethal hit before `battle.kill`, while the
* target is still `alive` at 0 HP — element riders attached to that damage (and applyElement itself) skip such a
* target, or a burst resolves on the corpse (a second fatal, 烛煌 熔点引爆's heal, 妮芙's stack …).
*/
export function hasHp(u) {
return !!u && u.alive && (u.bossPool ? u.bossPool.hp > 0 : u.hp > 0);
}
/** Normalise a DamageInfo descriptor. */
export function makeDamageInfo(d = {}) {
const type = d.type ?? 'phys';
@@ -52,6 +80,7 @@ export function makeDamageInfo(d = {}) {
cancel: false,
noSp: !!d.noSp,
ignoreSleep: !!d.ignoreSleep,
sourceless: !!d.sourceless,
attackId: d.attackId ?? 0,
};
}
@@ -61,7 +90,10 @@ function sleepBlocks(target, source, dmg) {
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
}
/** Pure mitigation formula (exported for tests). */
/**
* Pure mitigation formula (exported for tests). `target` = the target's stats; `ign.elementalRes` = its 元素抗性
* (epDamageResistance) for 'elemental' damage.
*/
export function mitigate(amount, type, target, ign = {}) {
if (type === 'phys') {
const D = target.def ?? 0;
@@ -73,6 +105,10 @@ export function mitigate(amount, type, target, ign = {}) {
const eff = Math.max(0, R * (1 - clamp01(ign.resIgnorePct ?? 0)) - (ign.resIgnoreFlat ?? 0));
return Math.max(amount * (1 - Math.min(100, eff) / 100), MIN_DAMAGE_RATIO * amount);
}
if (type === 'elemental') {
const D = Math.max(0, Math.min(100, Number.isFinite(ign.elementalRes) ? ign.elementalRes : 0));
return Math.max(amount * (1 - D / 100), MIN_DAMAGE_RATIO * amount);
}
return amount;
}
@@ -115,14 +151,18 @@ export function dealDamage(battle, source, target, dmgIn) {
if (!target || !target.alive || target.removed || target.hidden || !target.deployed) return 0;
const dmg = dmgIn && dmgIn._norm ? dmgIn : makeDamageInfo(dmgIn);
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
// 无来源 (dmg.sourceless, element bursts): hooks see no source — nothing keyed on "damage dealt by X" (ATK-up vs a
// tag, 伤害提升 items / bonds / modules, reflect, "受到来自…的伤害") can recognise it; `credit` names the unit that still
// gets the stats and the kill (PRTS 伤害分类 无来源 ③)
const hs = dmg.sourceless ? null : source;
let ts = target.s;
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
if (battle._hooks.hit) {
battle.emit('hit', { source, target, dmg });
battle.emit('hit', { source: hs, target, dmg, credit: source });
if (dmg.cancel || !target.alive || !target.deployed) return 0;
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
ts = target.s; // handlers may have changed the target's buffs (fragile, invulnerable, dodge…): never use stale stats
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (ts.flags.invulnerable || sleepBlocks(target, hs, dmg)) return 0;
}
const type = dmg.type;
// dodge
@@ -141,16 +181,19 @@ export function dealDamage(battle, source, target, dmgIn) {
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
}
const ss = source && source.s ? source.s : null;
// 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below
const ss = source && source.s && !dmg.sourceless ? source.s : null;
let final = mitigate(dmg.amount, type, ts, {
defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0),
defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0),
resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0),
elementalRes: type === 'elemental' ? (target.def?.epDamageResistance ?? 0) : 0,
});
let mul = dmg.mul * ts.dmgTakenMul;
// 脆弱 / "受到的伤害±" (dmgTakenMul) scale 物理、法术、真实 only; 元素伤害 takes 元素脆弱 alone (header)
let mul = dmg.mul * (type === 'elemental' ? 1 : ts.dmgTakenMul);
if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1);
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elemTakenMul : ts.trueTakenMul;
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
final *= mul;
if (!(final > 0) || !Number.isFinite(final)) final = 0;
final = absorbShields(battle, target, final);
@@ -163,6 +206,7 @@ export function dealDamage(battle, source, target, dmgIn) {
export function applyHpLoss(battle, source, target, amount, dmg) {
if (!target.alive) return 0;
let dealt = 0;
const hs = dmg && dmg.sourceless ? null : source; // the source hooks see (无来源: none; `credit` keeps it)
if (target.bossPool) {
const pool = target.bossPool;
const before = pool.hp;
@@ -180,7 +224,7 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
if (target.hp <= 0) {
target.hp = 0;
if (battle._hooks.fatal) {
const fctx = { unit: target, source, dmg, amount, prevented: false };
const fctx = { unit: target, source: hs, credit: source, dmg, amount, prevented: false };
battle.emit('fatal', fctx);
if (fctx.prevented && target.alive) { if (target.hp < 1) target.hp = Math.min(1, target.s.maxHp); }
}
@@ -197,7 +241,7 @@ export function applyHpLoss(battle, source, target, amount, dmg) {
const shown = dmg.type === 'element' ? dmg.element : dmg.type === 'elemental' ? (dmg.element || 'true') : dmg.type;
battle._ev(['dmg', target.id, Math.round(amount), shown]);
}
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount, type: dmg ? dmg.type : 'true', dmg });
if (battle._hooks.damaged) battle.emit('damaged', { source: hs, target, amount, type: dmg ? dmg.type : 'true', dmg, credit: source });
if (target.side === 'ally' && target.skill && dmg && !dmg.noSp && dmg.type !== 'element') battle._skills.onDamaged(target);
const dead = target.bossPool ? target.bossPool.hp <= 0 : target.hp <= 0;
if (dead && target.alive) battle.kill(target, source);
@@ -246,10 +290,21 @@ export function elementView(u, now) {
return [best, fill, 0, 0];
}
/**
* The share of an element hit that reaches `target`'s gauge (header): its 元素损伤倍率 (`elemTakenMul`) × max(5 %,
* 1 − 损伤抵抗 / 100) — the factor applyElement uses after the `elementHit` hook, exported for content that previews a
* gauge gain.
*/
export function elementIntake(target) {
const res = Number(target && target.def ? target.def.epResistance : 0) || 0;
return target.s.elemTakenMul * Math.max(MIN_DAMAGE_RATIO, 1 - clamp01(res / 100));
}
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
export function applyElement(battle, source, target, dmg) {
const el = dmg.element;
if (!el || !(el in target.elem)) return 0;
if (!hasHp(target)) return 0; // a killing blow's rider: the target is dead, nothing fills or bursts (header)
if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (burstLocked(target, el)) return 0;
if (battle._hooks.elementHit) {
@@ -258,8 +313,8 @@ export function applyElement(battle, source, target, dmg) {
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
}
const max = target.gaugeMax;
// 损伤抵抗 (official EP_RESISTANCE = data epResistance, a percentage)
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01((target.def?.epResistance ?? 0) / 100));
// 元素损伤倍率 × max(5 %, 1 − 损伤抵抗 %) — official EP_RESISTANCE = data epResistance
let amt = dmg.amount * dmg.mul * elementIntake(target);
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
target.elem[el] = Math.min(max, target.elem[el] + amt);
battle._ev(['dmg', target.id, Math.round(amt), el]);
@@ -290,7 +345,8 @@ function resolveBurst(battle, source, target, el) {
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
if (!target.alive) { reset(); return; }
const tags = ['burst', el];
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, tags });
// 无来源 damage credited to `source` (header)
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, sourceless: true, tags });
const lock = (duration, extra = {}) => {
if (!(duration > 0)) { reset(); return null; }
return battle.addBuff(target, { key: `${el}Burst`, duration, visible: true, onExpire: reset, onRemove: reset, ...extra, flags: { burstLock: true, ...(extra.flags || {}) } });
@@ -298,7 +354,7 @@ function resolveBurst(battle, source, target, el) {
if (el === 'necrosis') {
lock(cfg.duration, {
mods: { atkMul: 1 - cfg.atkDownPct }, interval: 1,
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, tags: ['burst', 'necrosis'] }),
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, sourceless: true, tags: ['burst', 'necrosis'] }),
});
return;
}
@@ -319,11 +375,11 @@ function resolveBurst(battle, source, target, el) {
}
} else if (el === 'apoptosis') {
if (enemy) {
// 50 % 虚弱 that recovers linearly over the burst, 800 元素伤害 per second
// 50 % 虚弱, 800 元素伤害 per second; each second the 虚弱 drops to 50 % × 剩余时间 ÷ 总持续时间 (PRTS 元素)
lock(c.duration, {
mods: { atkMul: 1 - c.weaken }, interval: 1,
onTick: ({ buff }) => {
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft - 1) / c.duration };
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft) / c.duration };
target.markDirty();
hit(c.elemDps, 'elemental');
},
@@ -354,17 +410,18 @@ export function palsyBuff(n) {
}
/**
* Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. Nothing recovers during a
* burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、无法被其他手段回复").
* Element healing (e.g. wandermedic "回复元素损伤"): lowers element `el`, or — without `el` — EVERY element type, each
* by `amount` on its own (PRTS 菲莱 备注: clearing = "一次等同于自身最大元素值(通常为1000)的全类型元素损伤治疗").
* Returns the total removed. Nothing recovers during a burst cooldown ("爆发冷却状态下,单位所有类型的元素值均无法损失、
* 无法被其他手段回复").
*/
export function reduceElement(target, amount, el = null) {
let removed = 0;
if (!target || !target.elem || burstLocked(target)) return 0;
if (!target || !target.elem || burstLocked(target) || !(amount > 0)) return 0;
const els = el ? [el] : Object.keys(target.elem);
for (const k of els) {
const take = Math.min(target.elem[k], amount - removed);
const take = Math.min(target.elem[k] || 0, amount);
if (take > 0) { target.elem[k] -= take; removed += take; }
if (removed >= amount) break;
}
return removed;
}
+22 -14
View File
@@ -21,7 +21,10 @@
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
// (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame.
import { toLocal } from './dir.js';
import { toLocal, frontOf } from './dir.js';
import { absoluteRangeKeys } from './targeting.js';
import { bodyInKeys, bodyKeys, bodyOnTile } from './body.js';
import { COLS } from './constants.js';
const P = (o) => Object.freeze(o);
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
@@ -239,19 +242,24 @@ const installSkywalker = (battle, unit) => {
};
/** Target's tile offset from the unit in the unit's facing-RIGHT frame ([dRow, dCol], sim/dir.js toLocal). */
const localOffset = (unit, target) => toLocal(Math.round(target.y) - unit.tileR, Math.round(target.x) - unit.tileC, unit.dir);
/** The target (its body: a huge enemy's every tile — body.js) on a tile of `grid` around the unit (its facing frame). */
const inTraitGrid = (unit, target, grid) => bodyInKeys(target, absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0));
const inTraitGrid = (unit, target, grid) => {
const [dr, dc] = localOffset(unit, target);
for (const [r, c] of grid) if (r === dr && c === dc) return true;
return false;
};
/** 'reaperrange' default front test: the target stands on the unit's own line, at or ahead of it along its facing. */
/**
* 'reaperrange' default front test: the target stands on the unit's own line, at or ahead of it along its facing — a
* huge enemy when any tile of its body does (body.js).
*/
const onFrontLine = (unit, target) => {
const [fr, fc] = unit.fwd;
const [lateral] = localOffset(unit, target);
return lateral === 0 && (target.x - unit.x) * fc + (target.y - unit.y) * fr >= 0;
const lateral = (r, c) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir)[0];
if (!target.hitArea) {
return lateral(Math.round(target.y), Math.round(target.x)) === 0 && (target.x - unit.x) * fc + (target.y - unit.y) * fr >= 0;
}
for (const k of bodyKeys(target)) {
const r = Math.floor(k / COLS), c = k % COLS;
if (lateral(r, c) === 0 && (c - unit.tileC) * fc + (r - unit.tileR) * fr >= 0) return true;
}
return false;
};
// --------------------------------------------------------------------------------------------------------------
@@ -269,7 +277,7 @@ export const SUB = Object.freeze({
const n = Math.max(1, (unit.profile.shockTimes ?? 2) - 1);
for (let i = 1; i <= n; i++) {
battle.after(0.3 * i, () => {
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1)) {
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1, true)) { // splash: 中点判定
if (e.isFlying) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * (unit.profile.shockScale ?? 0.5), type: 'phys', isSplash: true, tags: ['aftershock'] });
}
@@ -342,8 +350,8 @@ export const SUB = Object.freeze({
lord: P({ canHitFly: true,
dmgMul: (battle, unit, target) => {
if (target.blockedBy === unit) return 1;
const [dr, dc] = localOffset(unit, target);
return dr === 0 && (dc === 0 || dc === 1) ? 1 : (unit.profile.rangedScale ?? 0.8);
const [fr, fc] = frontOf(unit.tileR, unit.tileC, unit.dir);
return bodyOnTile(target, unit.tileR, unit.tileC) || bodyOnTile(target, fr, fc) ? 1 : (unit.profile.rangedScale ?? 0.8);
} }),
musha: P({ noHeal: true, install: installSelfHealOnHit(false) }),
reaper: P({ noHeal: true, allInRange: true, install: installSelfHealOnHit(true) }),
+2
View File
@@ -25,6 +25,7 @@
// getSimData() the injected data (browser) or the generated data (Node), or null.
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
import { normHitArea } from './body.js';
// ---------------------------------------------------------------------------------------------------------------
// generic helpers
@@ -258,6 +259,7 @@ export function normalizeEnemy(key, e) {
epDamageResistance: num(st.elementDmgRes ?? extra.epDamageResistance ?? e.epDamageResistance, 0),
hpRecoveryPerSec: num(st.hpRecoveryPerSec ?? extra.hpRecoveryPerSec ?? e.hpRecoveryPerSec, 0),
notCountInTotal: !!(e.notCountInTotal),
hitArea: normHitArea(e.hitArea), // huge units only (body.js); null = a point
tags: e.tags ?? [],
abilities: e.abilities ?? [],
skills: e.skills ?? [],
+3
View File
@@ -277,6 +277,9 @@ export class SkillRuntime {
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
}
// the enemies a melee unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
}
+44 -11
View File
@@ -2,13 +2,17 @@
//
// Grid ranges: `[dRow, dCol]` offsets relative to facing RIGHT, rotated by the unit's direction (sim/dir.js: RIGHT
// (dr,dc), UP (dc,−dr), LEFT (−dr,−dc), DOWN (−dc,dr); row 0 = bottom). An enemy is in range when the tile containing
// it (round(y), round(x)) is one of the absolute range tiles. `rangeExtend` (ability_range_forward_extend) adds N tiles
// it (round(y), round(x)) is one of the absolute range tiles — a huge enemy (巨型单位) when any tile its hit rectangle
// occupies is (body.js; user playtest #5 item 10). `rangeExtend` (ability_range_forward_extend) adds N tiles
// past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3).
// Operator priority: (1) enemies it blocks, (2) profile priority (fly/lowDef/…), (3) higher enemy taunt,
// (4) least remaining path distance to the goal, (5) earliest spawned.
// Operator priority: (1) enemies it blocks (melee units only: meleeUnit), (2) profile priority (fly/lowDef/…),
// (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units
// (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy
// priority: sortAllyTargets.
import { COLS, ROWS } from './constants.js';
import { normDir, rotateOffset } from './dir.js';
import { bodyDist } from './body.js';
/**
* Build the absolute tile-key list of a grid for a unit standing at (r,c) facing `dir` ('UP'|'RIGHT'|'DOWN'|'LEFT';
@@ -45,6 +49,18 @@ export function tileKeyOf(u) {
return r * COLS + c;
}
/**
* A melee unit (近战位: data `position` MELEE — melee-tile operators and summons, 重装要塞 / 领主 / 哨戒铁卫 included):
* it may always select, and selects first, the enemies it blocks — PRTS 选择器 "可以选择且优先选择阻挡单位" (the
* BlockedOrAdvancedSelector of units whose range is smaller than their block radius), 索敌的概念 "我方索敌优先级:
* 阻挡(近战限定)". A ranged operator standing on a melee tile (卫戍协议 "所有行动内远程干员可部署在近战位") blocks, but
* targets by its range alone: "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后,该干员
* 可能因为敌人未处于自身攻击范围而无法攻击到这个敌人" (索敌的概念). Used by Battle.blockedTargets and sortEnemyTargets.
*/
export function meleeUnit(u) {
return String(u?.def?.position ?? 'MELEE').toUpperCase() === 'MELEE';
}
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
@@ -56,7 +72,13 @@ export function canTargetEnemy(attacker, e, profile) {
return true;
}
/** Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. */
/**
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿 / 迷彩, the
* 排气格栅 tile) is a target only for the enemy it blocks — PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿" and 索敌的
* 概念 "敌人会在自身被干员阻挡情况下强行无视对方可选性发动攻击" (the term text "不阻挡时…" is the short form; 异常效果:
* "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and
* off the enemy paths — PRTS).
*/
export function canTargetAlly(e, a, ranged) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
const f = a.s.flags;
@@ -89,10 +111,11 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
if (cands.length <= 1) return cands;
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
const ax = attacker.x, ay = attacker.y;
const melee = meleeUnit(attacker);
const keyed = cands.map((e) => ({
e,
b: e.blockedBy === attacker ? 0 : 1,
p: pf ? pf(e) : priority === 'nearest' ? Math.hypot(e.x - ax, e.y - ay) : priority === 'farthest' ? -Math.hypot(e.x - ax, e.y - ay) : 0,
b: melee && e.blockedBy === attacker ? 0 : 1,
p: pf ? pf(e) : priority === 'nearest' ? bodyDist(e, ax, ay) : priority === 'farthest' ? -bodyDist(e, ax, ay) : 0,
t: -(e.s.taunt || 0),
d: battle.remainingDistance(e),
s: e.spawnSeq,
@@ -103,16 +126,26 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
}
/**
* Enemy target selection among allies: blocker first → highest taunt → latest deployed (DESIGN §5.5).
* Enemy target selection among allies (PRTS 作战机制 索敌, 敌方: "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→
* 不容易被攻击)→最早出现"): its blocker first → highest taunt level → latest deployed (`aggroSeq`: the deploy order —
* Battle.start deploys the operators first and ranks the start-of-battle summons after them). Special priorities
* (优先攻击…) are the enemies' own content: `e.profile.canTarget` filters, key sorts broken by `aggroCmp`.
*/
export function sortAllyTargets(enemy, cands) {
if (cands.length <= 1) return cands;
cands.sort((a, b) => {
const ba = enemy.blockedBy === a ? 0 : 1, bb = enemy.blockedBy === b ? 0 : 1;
if (ba !== bb) return ba - bb;
const ta = a.s.taunt || 0, tb = b.s.taunt || 0;
if (ta !== tb) return tb - ta;
return b.deploySeq - a.deploySeq;
return ba - bb || aggroCmp(a, b);
});
return cands;
}
/**
* 仇恨值 order of two allies for an enemy (negative ⇒ `a` first): higher taunt level, then the latest deployed
* (`aggroSeq`). The tie-break after an enemy's special priority (优先攻击防御力最高的… — PRTS 索敌: 特殊优先级 → 仇恨值).
*/
export function aggroCmp(a, b) {
const ta = a.s.taunt || 0, tb = b.s.taunt || 0;
if (ta !== tb) return tb - ta;
return (b.aggroSeq || b.deploySeq) - (a.aggroSeq || a.deploySeq);
}
+15 -4
View File
@@ -71,7 +71,8 @@ export class Unit {
this.deployedAt = -Infinity;
this.deathAt = -Infinity;
this.respawnAt = Infinity;
this.deploySeq = 0;
this.deploySeq = 0; // deployment counter; also the identity of one deployment (content: `seq === u.deploySeq`)
this.aggroSeq = 0; // 仇恨 order (the later the more attacked): = deploySeq, except summons of the initial deployment
this.spawnSeq = 0;
this.elem = { burn: 0, neural: 0, necrosis: 0, apoptosis: 0, erosion: 0 };
this.burstPending = null; // { [element]: true } while that element's burst resolves (damage.js burstLocked)
@@ -142,7 +143,8 @@ export class Unit {
physTakenMul: m('physTakenMul'),
artsTakenMul: m('artsTakenMul'),
trueTakenMul: m('trueTakenMul'),
elemTakenMul: m('elemTakenMul'),
elemTakenMul: m('elemTakenMul'), // 元素损伤倍率: element gauge fills (damage.js applyElement)
elementalTakenMul: m('elementalTakenMul'), // 元素脆弱: 元素伤害 (the 'elemental' HP damage type)
healingDealtMul: m('healingDealtMul'),
healingTakenMul: m('healingTakenMul'),
atkScaleMul: m('atkScaleMul'),
@@ -169,8 +171,17 @@ export class Unit {
hasFlag(k) { return !!this.s.flags[k]; }
/** Ground unit: enemies that WALK and aren't levitated; ally units standing on a LOW tile. */
get isFlying() { return this.motion === 'FLY'; }
/**
* Air unit (空中单位) for every targeting / ground-only rule: FLY movers, and enemies that hover (近地悬浮, buff flag
* `float` — PRTS 术语释义 ba.float "算作空中单位"; they keep walking the ground path) or are levitated (浮空, gamedata_const
* ba.levitate "变为空中单位"). Movement and pathing read `motion`, never this.
*/
get isFlying() {
if (this.motion === 'FLY') return true;
if (this.side !== 'enemy') return false;
const f = this.s.flags;
return !!(f.float || f.levitate);
}
get hpRatio() { const mh = this.s.maxHp; return mh > 0 ? this.hp / mh : 0; }
+15 -7
View File
@@ -127,7 +127,7 @@ test(`${nm('enemy_1404_msnip')}: 直击 — a unit in line is shot (arts ATK×at
assert.ok(e.findBuff('ab:revealed') || h.b.time > s.initCooldown + s.bb.duration);
});
test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns around itself`, () => {
test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns its locked target`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
h.step();
const free = put(h, 'enemy_10034_cnvsax', [9, 5]); // next to the wall, not blocked
@@ -137,7 +137,7 @@ test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked i
h.run(5);
const w = h.unit('t_wall');
assert.ok(e.stats.attacks > 0);
assert.ok(w.elem.burn > 0, 'fire aura burns');
assert.ok(w.elem.burn > 0, '狂欢式演奏 burns its target (the blocker)');
});
test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthed`, () => {
@@ -483,7 +483,11 @@ test(`${nm('enemy_9007_acelem')}: harder to target (taunt −1); parasitises its
h.run(2.6);
const w = h.unit('t_wall');
assert.ok(w.findBuff('ab:parasite'));
approx(w.s.elemTakenMul, tb('enemy_9007_acelem', '1.ep_damage_scale'));
// "受到的元素损伤提高至130%": an elementHit multiplier on the host's gauge fills (SIM.md §7.2), not the elemTakenMul mod
assert.equal(w.s.elemTakenMul, 1);
const n0 = w.elem.neural;
h.b.dealDamage(null, w, { type: 'element', element: 'neural', amount: 100 });
approx(w.elem.neural - n0, 100 * tb('enemy_9007_acelem', '1.ep_damage_scale'), 1e-9);
approx(w.stats.taken, 2 * e.s.atk * tb('enemy_9007_acelem', '1.atk_scale'), 1e-6);
killed(h, e, null);
assert.ok(!w.findBuff('ab:parasite'));
@@ -1140,14 +1144,16 @@ test(`${nm('enemy_10044_wintun')}: fast with its barrel; first hit ×${skb('enem
approx(e.s.dodgePhys, skb('enemy_10044_wintun', 'BlockedBoom').bb.prob);
});
test(`${nm('enemy_10045_parrot')}: 近地悬浮; sprints when first hit`, () => {
test(`${nm('enemy_10045_parrot')}: 近地悬浮; sprints when first hit (final ×M0SpeedUp.move_speed — PRTS "最终提升至300%")`, () => {
const h = arena();
h.step();
const e = put(h, 'enemy_10045_parrot', [10, 7], { move: true });
const v = e.s.moveSpeed;
assert.ok(e.mem.ab.float && e.s.flags.unblockable);
assert.ok(e.findBuff('ab:float') && e.s.flags.unblockable && e.isFlying && e.motion === 'WALK');
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
approx(e.s.moveSpeed, v * (1 + tb('enemy_10045_parrot', 'M0SpeedUp.move_speed')));
approx(e.s.moveSpeed, v * tb('enemy_10045_parrot', 'M0SpeedUp.move_speed'));
h.run(tb('enemy_10045_parrot', 'M0SpeedUp.duration') + 0.1);
approx(e.s.moveSpeed, v);
});
test(`${nm('enemy_10087_hlchgr')}: spends ammo every ${tb('enemy_10087_hlchgr', 'SkillTrigger.interval')} s for permanent speed and ATK`, () => {
@@ -1173,7 +1179,9 @@ test(`${nm('enemy_10116_ymgtop')}: spinning phase deals ATK×${tb('enemy_10116_y
h.run(skb('enemy_10116_ymgtop', 'SwitchModeTrigger').initCooldown + 3.05);
const w = h.unit('t_wall');
assert.ok(w.stats.taken >= 2 * e.s.atk * tb('enemy_10116_ymgtop', 'RotateDamage.attack@atk_scale') - 1e-6);
h.b.displace(e, { x: -1, y: 0 }, 0.5, { force: 3 }); // pushed back towards the wall: still in reach
// pushed towards the wall (to x 6.8): still in reach, not in contact (0.8 > block radius 0.71: the wall would
// block it)
h.b.displace(e, { x: -1, y: 0 }, 0.2, { force: 3 });
h.step();
const t0 = w.stats.taken;
h.run(3);
+17 -8
View File
@@ -491,12 +491,16 @@ test('coin / refresh counters: 溯光星源 121 per 3 spent, 阿罗玛 122 / 安
// ---------------------------------------------------------------------------------------------------------------------
// price, items, refreshes, funds
test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红豆 04 (hidden) costs 1', () => {
test('SERVER_CHESS_PRICE: 购买价格为1 — bb.price is the discount off the tier price (至简 Ⅲ 3 − 2, 红豆 Ⅰ 2 − 1; user playtest #5)', () => {
for (const { gid, g, owners } of idsOf('SERVER_PRICE', 'SERVER_CHESS_PRICE')) {
const n = Number(/购买价格为(\d+)/.exec(g.desc)?.[1]);
assert.equal(n, 1, `${gid}: the official text`);
for (const owner of owners) {
const s = setup();
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: s.m.gd.chessPrice(owner), frozen: false, sold: false };
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), g.bb.price, `${gid} ${owner}`);
const tierPrice = s.m.gd.chessPrice(owner);
assert.equal(tierPrice - g.bb.price, n, `${gid} ${owner}: tier price − bb.price = the text's price`);
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: tierPrice, frozen: false, sold: false };
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), n, `${gid} ${owner}`);
}
cover(gid);
}
@@ -504,20 +508,25 @@ test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
const funds = s.ps.funds;
assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(s.ps.funds, funds - 2);
assert.equal(s.ps.funds, funds - 1, '至简 costs 1');
assert.equal(GR('garrison_13_a').bb.price, 2);
assert.equal(GR('garrison_13_b').bb.price, 2);
});
test('regression: 购买价格 replaces the base price but keeps discounts applied earlier in the dispatch (远见-style −1)', () => {
test('regression: 购买价格为1 runs before the other price modifiers (远见 −1 never below 1 leaves 至简 at 1)', () => {
const s = setup();
s.m.dispatcher.registry.global('gar_test_disc', { onPrice(ctx) { ctx.modifyPrice(-1); } });
const seen = [];
s.m.dispatcher.registry.global('gar_test_seen', { onPrice(ctx, ev) { seen.push(ev.price); } });
try {
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
s.ps.shop.slots[1] = { kind: 'chess', id: 'chess_char_1_02_a', basePrice: 2, frozen: false, sold: false };
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 2 − 1');
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1);
assert.deepEqual(seen, [1], 'a global modifier already sees 至简 at 1');
s.ps.counters['bondaddon:visi:disc'] = 2; // 远见 150 layers: every chess −1, never below 1
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 3 − 2 = 1; 远见 leaves it at 1');
assert.equal(s.ps.priceOf(s.ps.shop.slots[1]), 1, 'other chess: 2 − 1');
} finally {
s.m.dispatcher.registry.unregister('global:gar_test_disc');
s.m.dispatcher.registry.unregister('global:gar_test_seen');
}
});
+3 -1
View File
@@ -746,7 +746,9 @@ test('夕 S3 写意胜形: BAT +0.4 s, ATK +, splash 1.7; every attack summons /
test('夕 S3 写意胜形: unblocked enemies first (the blocked one is attacked only without another choice)', () => {
const h = run({
defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e7, speed: 1 }), enemy_dummy: dummy('enemy_dummy') }, chess: { t_block: ally('t_block', { stats: { blockCnt: 1 } }) } },
units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 5 }],
// (the blocker at col 4: the walker stops 0.71 tile in front of it — block radius, user playtest #5 — on col 5,
// inside 夕's 3-column range)
units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 4 }],
enemies: [{ key: 'enemy_walk', route: 0 }, { key: 'enemy_dummy', pos: [10, 6] }],
});
const u = h.unit('chess_char_5_12_a');
+30 -3
View File
@@ -441,7 +441,7 @@ test('1_14 格雷伊: 静电场 停顿 on the attacked target, ×talent_scale du
done(h1); done(h2);
});
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经损伤 per hit; skill 2 targets + ASPD; elite boost vs ELITE', () => {
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经 + 灼燃 + 凋亡 per damage; skill 2 targets + ASPD; elite boost vs ELITE', () => {
const id = 'chess_char_1_15_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
@@ -450,7 +450,9 @@ test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经损伤 per
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
assert.equal(h.hooksOf('attack').find((c) => c.attacker === u).targets.length, bb['attack@max_target']);
const el = dealt(h, u, (c) => c.type === 'element');
assert.ok(el.length >= 2 && el.every((c) => c.dmg.element === 'neural'));
assert.ok(el.length >= 6);
// every damage she deals attaches all three, in the official order (神经 "(优先)", then 灼燃, then 凋亡)
for (let i = 0; i + 2 < el.length; i += 3) assert.deepEqual(el.slice(i, i + 3).map((c) => c.dmg.element), ['neural', 'burn', 'apoptosis']);
for (const c of el) approx(c.amount, u.s.atk * t.ep_damage_ratio);
done(h);
@@ -724,7 +726,32 @@ test('2_05 哈洛德: 重症优先 heals the heaviest element load first with ×
assert.equal(first.targets[0], elem, 'element-damaged ally first');
approx(elem.elem.neural, 900 - u.s.atk * tbOf(id).ep_heal_ratio * bb.trait_scale, 'recovery ×1.6');
h.run(0.3);
approx(elem.s.elemTakenMul, 1 - t.ep_damage_resistance, '我即军营');
// 我即军营 cuts the 元素损伤 (gauge fill) of an ally over half, on the hit itself — not elemTakenMul (元素伤害 / 元素脆弱)
assert.ok(elem.elem.neural > 500, 'still over half');
const b0 = elem.elem.burn;
h.b.dealDamage(null, elem, { type: 'element', element: 'burn', amount: 100 });
approx(elem.elem.burn - b0, 100 * (1 - t.ep_damage_resistance), '我即军营');
approx(elem.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
// under half: no cut
const n0 = low.elem.neural;
h.b.dealDamage(null, low, { type: 'element', element: 'neural', amount: 100 });
approx(low.elem.neural - n0, 100, 'gauge under half: full fill');
done(h);
});
test('2_05 哈洛德 (user playtest #5 #3): 侵蚀 counts as element damage for 重症优先 and 我即军营', () => {
const id = 'chess_char_2_05_a', t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 11, col: 5 }] });
const u = h.unit(id), low = h.unit('chess_char_1_02_a'), eroded = h.unit('chess_char_1_10_a');
h.step();
low.hp = low.s.maxHp * 0.3;
eroded.elem.erosion = 900;
h.step();
const first = h.hooksOf('attack').find((c) => c.attacker === u);
assert.equal(first.targets[0], eroded, 'the eroded ally is the heaviest element load');
const e0 = eroded.elem.erosion;
h.b.dealDamage(null, eroded, { type: 'element', element: 'erosion', amount: 100 });
approx(eroded.elem.erosion - e0, 100 * (1 - t.ep_damage_resistance), '我即军营 on an eroded ally over half');
done(h);
});
+4 -2
View File
@@ -191,9 +191,11 @@ test('3_06 菲莱: 冥河诅咒 no attacks, HP +, counter arts + apoptosis (cd),
const u = h.unit(id);
h.run(0.2);
const e = h.enemy('enemy_h');
approx(u.s.elemTakenMul, 1 - t0.damage_resistance);
const sp0 = u.skill.sp;
// 神河谕使: a 元素损伤 multiplier on the hit (the gauge fill), not elemTakenMul (元素伤害 / 元素脆弱)
approx(u.s.elemTakenMul, 1);
const sp0 = u.skill.sp, a0 = u.elem.apoptosis;
h.b.dealDamage(e, u, { type: 'element', element: 'apoptosis', amount: 10 });
approx(u.elem.apoptosis - a0, 10 * (1 - t0.damage_resistance), 1e-6, 'element taken −damage_resistance');
approx(u.skill.sp, sp0 + t0.sp, 1e-6, '+SP on apoptosis');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
+4 -4
View File
@@ -972,7 +972,7 @@ test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and nev
clean(h);
});
test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
test('元素伤害 uses the engine elemental type: × elementalTakenMul (元素脆弱), never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
// one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
const arena = () => {
const h = makeBattle({
@@ -983,7 +983,7 @@ test('元素伤害 uses the engine elemental type: × elemTakenMul, never × tru
h.step();
const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
h.b.addBuff(e, { key: 't:elem', mods: { elementalTakenMul: 1.5 } });
return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
};
const a = arena();
@@ -991,14 +991,14 @@ test('元素伤害 uses the engine elemental type: × elemTakenMul, never × tru
const melt = tagged(a.h, 'blazeMelt', a.e);
assert.equal(melt.length, 1);
assert.equal(melt[0].type, 'elemental');
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elementalTakenMul only');
clean(a.h);
const { h, e, nymph } = arena();
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
const soul = tagged(h, 'nymphSoul', e)[0];
assert.equal(soul.type, 'elemental');
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elemTakenMul only');
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elementalTakenMul only');
clean(h);
});
+3 -3
View File
@@ -143,14 +143,14 @@ test('#12 炎佑: 元素脆弱 aura within 1.5 of itself, immune to element dama
const [near, far] = h.enemies();
assert.ok(near.findBuff('elemFragile'), '1.2 away: 元素脆弱');
assert.ok(!far.findBuff('elemFragile'), '3.2 away: none');
assert.ok(Math.abs(near.s.elemTakenMul - 1.2) < 1e-9, 'element damage ×1.2');
assert.ok(Math.abs(near.s.elementalTakenMul - 1.2) < 1e-9, '元素伤害 ×1.2');
// element damage on the dragon is cancelled
h.b.dealDamage(near, y, { type: 'element', element: 'burn', amount: 800 });
assert.equal(y.elem.burn, 0, 'no burn gauge on 炎佑');
// normal hits: burn = ATK × 0.2 (× the 1.2 fragility) per hit
// normal hits: burn = ATK × 0.2 per hit — the 1.2 元素脆弱 (2.damage_scale) raises 元素伤害, never the gauge fill
const before = near.elem.burn;
assert.ok(h.runUntil(() => near.elem.burn > before, 5));
const tal = h.b.data.rawToken(TOKEN_IDS.yanyou).talents.bb;
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio'] * tal['2.damage_scale']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
checkInvariants(h.b);
});
+231
View File
@@ -0,0 +1,231 @@
// User playtest #5 report #3 (operator side, workstream W3a): element damage (元素损伤) dealt by the player side and the
// operator-side gauge modifiers, against the official rules.
// * 盟约·辅助干员 迭代元素 — every damage she deals attaches 神经 + 灼燃 + 凋亡 (18 % ATK each, 神经 first); the kit used
// to fill 神经 only (its "first element not bursting" pick was dead: a burst locks every gauge). "造成伤害时": a
// dodged / fully absorbed hit attaches nothing. Elite RIT-X: ep_damage_ratio_boss vs elite / leader — the talent
// only.
// * 菲莱 精锐 PRP-X "阻挡敌人时,自身造成的元素损伤提升15%" — every element fill she deals (a carried 灼燃维式重锤 too);
// S2 冥河诅咒's counter area = range x-4 (the 3×3 tiles around her, PRTS 备注).
// * 受到的元素损伤降低 (菲莱 神河谕使, 哈洛德 我即军营, 纯烬艾雅法拉 火山灰疗愈) — a 元素损伤 multiplier on the hit, not
// elemTakenMul (元素伤害 / 元素脆弱: gamedata_const termDescriptionDict ba.elementfragile "受到的元素伤害提升").
// * a killing blow attaches no element (迭代元素, 灼燃维式重锤, 炎佑, 妮芙's S2 rider): their `damaged` hooks run before the
// kill, while the target is `alive` at 0 HP — a fill there used to burst the corpse (a second fatal, 烛煌's heal).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/tier6.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { COLS } from '../../server/sim/constants.js';
const SRC = getDefaultSource();
const raw = (id) => SRC.rawChess(id);
const tal = (id, i = 0) => raw(id).talents.filter((t) => t.index !== -1)[i].bb;
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} ≠ ${b}`);
const HAMMER = 'chess_item_4_09_e_a'; // 灼燃维式重锤: arts damage attaches 10 % of it as 灼燃损伤
const DEFS = {
enemies: {
enemy_w3a: dummy('enemy_w3a'), enemy_w3a_elite: dummy('enemy_w3a_elite', { rank: 'ELITE' }),
enemy_w3a_hit: dummy('enemy_w3a_hit', { atk: 150, bat: 0.5 }), enemy_w3a_fly: dummy('enemy_w3a_fly', { motion: 'FLY' }),
enemy_w3a_low: dummy('enemy_w3a_low', { hp: 5 }),
},
chess: { t_guard: chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { maxHp: 5000 }, skill: null }) },
};
const battle = (o) => makeBattle({ defs: DEFS, timeLimit: 120, autoFinish: false, seed: 5, ...o });
const PITH = 'chess_char_1_15_a', PITH_B = 'chess_char_1_15_b';
// ---------------------------------------------------------------------------------------------------------------------
// 盟约·辅助干员
test('#3 盟约·辅助干员: each damage attaches 18 % ATK of 神经, 灼燃 and 凋亡 (in that order); alone, 神经 bursts', () => {
assert.deepEqual([...PITHST_ELEMENTS], ['neural', 'burn', 'apoptosis']);
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 5] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
const r = tal(PITH).ep_damage_ratio;
assert.ok(h.runUntil(() => e.elem.neural > 0, 5), 'first hit');
for (const el of PITHST_ELEMENTS) approx(e.elem[el], u.s.atk * r, `${el} = 18 % ATK`);
// the three gauges fill together; 神经 is applied first, so it is the one that bursts (PRTS 元素)
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').length > 0, 40), 'a burst');
assert.equal(h.hooksOf('elementBurst')[0].element, 'neural');
assert.equal(h.hooksOf('elementBurst').length, 1, 'the burst locks the other two');
checkInvariants(h.b);
});
test('#3 盟约·辅助干员: her 灼燃 / 凋亡 complete the gauges her team builds (was: 神经 only)', () => {
for (const el of ['burn', 'apoptosis']) {
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 8] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
e.elem[el] = 950; // e.g. 余's burn / 塑心's apoptosis so far
h.b.dealDamage(u, e, { amount: 100, type: 'arts', canDodge: false });
const b = h.hooksOf('elementBurst').filter((c) => c.target === e);
assert.equal(b.length, 1, `${el} burst`);
assert.equal(b[0].element, el);
const one = u.s.atk * tal(PITH).ep_damage_ratio;
approx(e.elem.neural, one, '神经 filled first');
// 灼燃 comes before 凋亡: it fills when 凋亡 bursts; the burst locks 凋亡 when 灼燃 bursts
if (el === 'apoptosis') approx(e.elem.burn, one, '灼燃 before the 凋亡 burst');
else assert.equal(e.elem.apoptosis, 0, 'the burst locks the element after it');
}
});
test('#3 盟约·辅助干员 "造成伤害时": a dodged or fully absorbed hit attaches no element; element damage never re-triggers it', () => {
for (const guard of [{ key: 't:barrier', shield: 1e9 }, { key: 't:dodge', mods: { dodgeArts: 1 } }]) {
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 5] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.addBuff(e, guard);
const atk0 = u.stats.attacks;
h.run(6);
assert.ok(u.stats.attacks >= atk0 + 2, 'she attacked');
assert.equal(e.elem.neural + e.elem.burn + e.elem.apoptosis, 0, `${guard.key}: nothing attached`);
}
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 8] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 10 });
h.b.dealDamage(u, e, { type: 'elemental', element: 'burn', amount: 10 });
assert.equal(e.elem.neural, 0, 'element fills / 元素伤害 are not "damage she deals" for 迭代元素');
});
test('#3 盟约·辅助干员 (elite): the RIT-X ratio vs elite enemies is the talent only — a carried 灼燃维式重锤 stays 10 %', () => {
const h = battle({ units: [{ chessId: PITH_B, row: 10, col: 4, items: [HAMMER] }], enemies: [{ key: 'enemy_w3a_elite', pos: [10, 8] }] });
const u = h.unit(PITH_B);
h.step();
const e = h.enemy('enemy_w3a_elite');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
const t = tal(PITH_B);
assert.ok(t.ep_damage_ratio_boss > t.ep_damage_ratio);
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
approx(e.elem.neural, u.s.atk * t.ep_damage_ratio_boss, 'talent ×1.18 vs elite');
approx(e.elem.burn, u.s.atk * t.ep_damage_ratio_boss + 1000 * 0.1, 'talent + hammer (10 % of the arts damage, no ×1.18)');
});
test('#3 a killing blow attaches no element: no burst on the corpse, one fatal, no 烛煌 熔点引爆 heal', () => {
const BLAZE = 'chess_char_5_03_a', NYMPH = 'chess_char_5_22_a';
const cases = [
['盟约·辅助干员 迭代元素', [{ chessId: PITH, row: 10, col: 4 }], (h) => h.unit(PITH), {}],
['灼燃维式重锤', [{ chessId: 't_guard', row: 10, col: 4, items: [HAMMER] }], (h) => h.unit('t_guard'), {}],
['炎佑', [], (h) => spawnYanyou(h.b, 'p1', { atk: 400, hp: 5000 })[0], {}],
['妮芙 S2 凋亡', [{ chessId: NYMPH, row: 11, col: 3 }], (h) => h.unit(NYMPH), { isSkill: true, isAttack: true }],
];
for (const [label, units, srcOf, flags] of cases) {
const h = battle({ units: [...units, { chessId: BLAZE, row: 11, col: 6 }], enemies: [{ key: 'enemy_w3a_low', pos: [10, 12] }] });
h.step();
const src = srcOf(h), blaze = h.unit(BLAZE), e = h.enemy('enemy_w3a_low');
assert.ok(e.alive && src && src.deployed && blaze.deployed, label);
// 灼燃 / 凋亡 one fill from bursting (a burn burst would trigger 烛煌 熔点引爆); 神经 empty (迭代元素 applies it first)
for (const k of Object.keys(e.elem)) e.elem[k] = k === 'burn' || k === 'apoptosis' ? 990 : 0;
blaze.hp = blaze.s.maxHp * 0.5;
h.b.dealDamage(src, e, { amount: 1000, type: 'arts', canDodge: false, ...flags });
assert.equal(e.alive, false, `${label}: killed`);
assert.equal(h.hooksOf('elementBurst').filter((c) => c.target === e).length, 0, `${label}: no burst on the corpse`);
assert.equal(h.hooksOf('fatal').filter((c) => c.unit === e).length, 1, `${label}: one fatal`);
assert.equal(h.hooksOf('kill').filter((c) => c.victim === e).length, 1, `${label}: one kill`);
assert.equal(blaze.hp, blaze.s.maxHp * 0.5, `${label}: no 熔点引爆 heal`);
checkInvariants(h.b);
}
// control: the same hit on a living target still attaches (and bursts)
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 12] }] });
h.step();
const u = h.unit(PITH), e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = k === 'burn' ? 990 : 0;
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
assert.deepEqual(h.hooksOf('elementBurst').filter((c) => c.target === e).map((c) => c.element), ['burn'], 'alive: 灼燃 bursts');
});
// ---------------------------------------------------------------------------------------------------------------------
// 菲莱
test('#3 菲莱 (elite): PRP-X ×1.15 applies to every element fill she deals while blocking — a carried 灼燃维式重锤 too', () => {
const id = 'chess_char_3_06_b';
const scale = raw(id).trait.bb.ep_damage_scale;
assert.ok(scale > 1);
for (const [pos, blocking] of [[[10, 5], true], [[10, 8], false]]) {
const h = battle({ units: [{ chessId: id, row: 10, col: 5, items: [HAMMER] }], enemies: [{ key: 'enemy_w3a', pos }] });
const u = h.unit(id);
h.run(0.2);
const e = h.enemy('enemy_w3a');
assert.equal(u.blocking.includes(e), blocking);
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
approx(e.elem.burn, 1000 * 0.1 * (blocking ? scale : 1), blocking ? 'hammer burn ×1.15 while blocking' : 'not blocking: ×1');
}
});
test('#3 菲莱 S2 冥河诅咒: the counter hits the ground enemies of the 3×3 tiles around her (range x-4), not a 1.5 radius', () => {
const id = 'chess_char_3_06_a';
const h = battle({
units: [{ chessId: id, row: 10, col: 5, carryState: { sp: 999 } }],
enemies: [
{ key: 'enemy_w3a_hit', pos: [10, 5] }, // blocked, attacks her → the counter
{ key: 'enemy_w3a', pos: [10.45, 6.45] }, // tile (10, 6) of the 3×3, 1.52 tiles away
{ key: 'enemy_w3a', pos: [10, 7] }, // two tiles away
{ key: 'enemy_w3a_fly', pos: [11, 6] }, // flying: not a ground enemy
],
});
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 10), 'S2');
const [near, far] = h.b.enemies.filter((x) => x.defId === 'enemy_w3a').sort((a, b) => a.x - b.x);
const fly = h.enemy('enemy_w3a_fly');
const ep = raw(id).skill.bb.ep_damage_ratio;
assert.ok(h.runUntil(() => near.elem.apoptosis > 0, 10), 'the off-centre enemy of the 3×3 is hit');
approx(near.elem.apoptosis, u.s.atk * ep, 'apoptosis = ep_damage_ratio × ATK');
assert.equal(far.elem.apoptosis, 0, 'two tiles away: not hit');
assert.equal(fly.elem.apoptosis, 0, 'flying: not hit');
});
// ---------------------------------------------------------------------------------------------------------------------
// 受到的元素损伤降低: gauge only
test('#3 纯烬艾雅法拉 火山灰疗愈: allies in range take −12 % 元素损伤 (×talent_scale in S3) — the gauge, not elemTakenMul', () => {
const id = 'chess_char_6_20_a';
const h = battle({ units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_guard', row: 10, col: 5 }] });
const u = h.unit(id), g = h.unit('t_guard');
h.run(0.6);
const er = tal(id, 1).ep_damage_resistance;
assert.ok(g.findBuff('agoat2:ash'), 'in her range');
const n0 = g.elem.neural;
h.b.dealDamage(null, g, { type: 'element', element: 'neural', amount: 100 });
approx(g.elem.neural - n0, 100 * (1 - er), '−12 %');
approx(g.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
// S3 (the default skill): the talent ×talent_scale
g.hp = g.s.maxHp * 0.5;
u.skill.sp = u.skill.spCost;
assert.ok(h.runUntil(() => u.skill.active, 10), 'S3');
h.run(0.6);
const b0 = g.elem.burn;
h.b.dealDamage(null, g, { type: 'element', element: 'burn', amount: 100 });
approx(g.elem.burn - b0, 100 * Math.max(0, 1 - er * raw(id).skill.bb.talent_scale), 'S3: ×talent_scale');
});
test('#3 纯烬艾雅法拉 氤氲: the bonus heal recovers heal_scale × the trait ep_heal_ratio × ATK of 元素损伤 per stack (was: heal_scale × ATK)', () => {
const id = 'chess_char_6_20_a';
// the guard stands outside her range: only the manual heals below reach it
const h = battle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: 't_guard', row: 9, col: 10 }] });
const u = h.unit(id), g = h.unit('t_guard');
h.step();
assert.ok(!u.rangeKeySet.has(g.tileR * COLS + g.tileC), 'guard out of her range');
const t0 = tal(id, 0), ratio = raw(id).trait.bb.ep_heal_ratio;
assert.ok(ratio > 0 && ratio < 1);
g.elem.neural = 900;
h.b.heal(u, g, 1); // a normal heal of hers → 1 stack
const n0 = g.elem.neural;
h.run(1.01);
approx(n0 - g.elem.neural, u.s.atk * t0.heal_scale * ratio, 'one stack: 10 % × 50 % ATK');
h.b.heal(u, g, 1); h.b.heal(u, g, 1); // 3 stacks (max), the duration refreshed
const mist = g.findBuff(`agoat2:mist:${u.id}`); // keyed per 纯烬 (two of them keep their own stacks)
assert.equal(mist.stacks, t0.max_stack_cnt);
const n1 = g.elem.neural;
h.run(1.01);
approx(n1 - g.elem.neural, u.s.atk * t0.heal_scale * ratio * t0.max_stack_cnt, '3 stacks');
});
+140
View File
@@ -0,0 +1,140 @@
// test/content/playtest5_followups.test.js — user playtest #5, cross-workstream follow-ups of the integration
// (docs/DESIGN.md §19): the element pipeline rules the workstreams left to the integrator, 塑心 S2's timing and
// 纯烬艾雅法拉's per-source 氤氲.
// * damage.js applyElement refuses a target with no HP left (hasHp — one shared helper): a rider in a `damaged` hook
// of a killing blow cannot burst the corpse, whatever content adds it.
// * 脆弱 (dmgTakenMul) does not scale 元素伤害 — gamedata_const ba.fragile "受到的物理、法术、真实伤害提升"; 元素脆弱
// (elementalTakenMul) does.
// * element healing lowers EVERY element type by the amount, each on its own — PRTS 菲莱 备注: "清除元素损伤" = "一次等同
// 于自身最大元素值(通常为1000)的全类型元素损伤治疗".
// * 塑心 S2 安魂的弥撒: "…凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" (PRTS 备注) — the 凋亡 lands
// before the partner's damage, so a killing blow still carries it.
// * 纯烬艾雅法拉 氤氲 is keyed per 纯烬 (two of them keep their own stacks and heal source).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { hasHp } from '../../server/sim/damage.js';
const SRC = getDefaultSource();
const raw = (id) => SRC.rawChess(id);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} ≠ ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const plain = (id, o = {}) => chessRec({ id, skill: null, stats: { maxHp: 1e6, ...(o.stats || {}) }, ...o });
// ---------------------------------------------------------------------------------------------------------------------
// damage.js
test('applyElement refuses a target with no HP left: a rider on a killing blow bursts nothing (pipeline guard)', () => {
for (const lethal of [true, false]) {
let riders = 0;
const h = makeBattle({
content: 'none', autoFinish: false, timeLimit: 30,
defs: { enemies: { enemy_fu: dummy('enemy_fu', { hp: 1000 }) } },
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
// a content rider with no HP check of its own: 1000 灼燃 on every damage it sees
setup: (b) => b.on('damaged', (c) => {
if (c.type === 'element' || c.type === 'elemental' || c.target.side !== 'enemy') return;
riders++;
b.dealDamage(null, c.target, { type: 'element', element: 'burn', amount: 1000 });
}),
});
h.step();
const e = h.enemy('enemy_fu');
h.b.dealDamage(null, e, { amount: lethal ? 5000 : 10, type: 'true' });
assert.equal(riders, 1, 'the rider ran');
if (lethal) {
assert.equal(e.alive, false);
assert.equal(e.elem.burn, 0, 'no fill on the corpse');
assert.equal(h.hooksOf('elementBurst').length, 0, 'no burst on the corpse');
assert.equal(h.hooksOf('fatal').length, 1, 'one fatal');
assert.equal(h.hooksOf('kill').length, 1, 'one kill');
} else {
assert.equal(h.hooksOf('elementBurst').length, 1, 'a living target still bursts');
}
checkInvariants(h.b);
}
assert.equal(hasHp(null), false);
assert.equal(hasHp({ alive: true, hp: 0 }), false);
assert.equal(hasHp({ alive: true, hp: 0, bossPool: { hp: 5 } }), true, 'a boss: its pool');
assert.equal(hasHp({ alive: false, hp: 5 }), false);
});
test('脆弱 does not scale 元素伤害 (ba.fragile: 物理、法术、真实); 元素脆弱 does', () => {
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { enemies: { enemy_fu: dummy('enemy_fu') } }, enemies: [{ key: 'enemy_fu', pos: [10, 6] }] });
h.step();
const e = h.enemy('enemy_fu');
const loss = (d) => { const hp0 = e.hp; h.b.dealDamage(null, e, d); return hp0 - e.hp; };
h.b.applyStatus(e, 'fragile', { duration: 10, value: 0.5 });
approx(loss({ amount: 1000, type: 'true' }), 1500, '脆弱 on true damage');
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1000, '脆弱 skips 元素伤害');
h.b.applyStatus(e, 'elemFragile', { duration: 10, value: 0.2 });
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1200, '元素脆弱');
approx(loss({ amount: 1000, type: 'true' }), 1500, '元素脆弱 skips true damage');
});
test('element healing lowers every element type by the amount, each on its own (PRTS 菲莱 备注)', () => {
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { chess: { g: plain('g') } }, units: [{ chessId: 'g', row: 10, col: 4 }] });
h.step();
const g = h.unit('g');
Object.assign(g.elem, { burn: 300, neural: 500, apoptosis: 0 });
approx(h.b.reduceElement(g, 400), 700, 'total removed');
assert.deepEqual([g.elem.burn, g.elem.neural, g.elem.apoptosis], [0, 100, 0], 'each type −400 (was: 400 in total)');
Object.assign(g.elem, { burn: 300, neural: 500 });
approx(h.b.reduceElement(g, 200, 'neural'), 200, 'one element');
assert.deepEqual([g.elem.burn, g.elem.neural], [300, 300]);
});
// ---------------------------------------------------------------------------------------------------------------------
// 塑心 S2
test('塑心 S2 安魂的弥撒: the partner\'s 凋亡 lands before its damage (PRTS 备注) — a killing blow still carries it', () => {
const id = 'chess_char_6_09_a', sid = 'skchr_cello_2';
const s2 = raw(id).skills.find((s) => s.skillId === sid);
for (const hp of [1e7, 50]) {
const seq = [];
const h = makeBattle({
seed: 7, autoFinish: false, timeLimit: 60,
defs: { chess: { big: plain('big', { stats: { atk: 2000 } }) }, enemies: { enemy_fu: dummy('enemy_fu', { hp }) } },
units: [{ chessId: id, row: 10, col: 4, skillIndex: s2.index, carryState: { sp: 999 } }, { chessId: 'big', row: 10, col: 5 }],
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
setup: (b) => {
b.on('elementHit', (c) => { if (c.dmg.element === 'apoptosis' && c.target.side === 'enemy') seq.push(['el', c.source?.defId, c.target.hp]); });
b.on('damaged', (c) => { if (c.source?.defId === 'big' && c.type === 'phys') seq.push(['dmg', 'big', c.target.hp]); });
},
});
const u = h.unit(id), big = h.unit('big');
assert.ok(h.runUntil(() => u.skill.active && u.mem.celloPartner === big, 5), 'S2 running, partner picked');
assert.ok(h.runUntil(() => seq.some((x) => x[0] === 'dmg'), 10), 'the partner hits');
const i = seq.findIndex((x) => x[0] === 'dmg');
assert.ok(i > 0 && seq[i - 1][0] === 'el' && seq[i - 1][1] === id, `the 凋亡 (source 塑心) right before the damage: ${JSON.stringify(seq.slice(0, i + 1))}`);
assert.ok(seq[i - 1][2] > 0, 'the target still had its HP when the 凋亡 landed');
if (hp < 1000) assert.equal(h.enemy('enemy_fu')?.alive ?? false, false, 'the killing blow landed after it');
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 纯烬艾雅法拉 氤氲
test('纯烬艾雅法拉 氤氲 is keyed per 纯烬: two of them keep their own stacks and each heals as itself', () => {
const id = 'chess_char_6_20_a';
const h = makeBattle({
seed: 5, autoFinish: false, timeLimit: 60,
defs: { chess: { g: plain('g') } },
// the ward stands outside both ranges: only the manual heals below reach it
units: [{ chessId: id, uid: 1, row: 12, col: 2 }, { chessId: id, uid: 2, row: 12, col: 3 }, { chessId: 'g', uid: 3, row: 9, col: 10 }],
});
h.step();
const a = h.unit(1), b = h.unit(2), g = h.unit(3);
h.b.heal(a, g, 1);
h.b.heal(b, g, 1);
h.b.heal(b, g, 1);
const ma = g.findBuff(`agoat2:mist:${a.id}`), mb = g.findBuff(`agoat2:mist:${b.id}`);
assert.ok(ma && mb, 'one 氤氲 per 纯烬');
assert.deepEqual([ma.stacks, mb.stacks], [1, 2]);
assert.equal(ma.source, a);
assert.equal(mb.source, b);
});
+3 -3
View File
@@ -478,7 +478,7 @@ test('炎佑: spawnYanyou — flying ally, bond stats, 3 targets with burn + ele
assert.ok(burned, 'burn gauge');
const frag = h.enemies().find((e) => e.findBuff('elemFragile'));
assert.ok(frag, 'elemental fragility (元素脆弱 status)');
approx(frag.s.elemTakenMul, 1.2, 1e-9);
approx(frag.s.elementalTakenMul, 1.2, 1e-9);
assert.ok(h.runUntil(() => y.skill.activations >= 1, 10), '祛恶之焰');
approx(y.skill.lastStart, 15, 1.5, 'first flame after initCooldown');
h.run(2);
@@ -885,8 +885,8 @@ test('炎佑 details: plain spawnToken works (enemy-shaped record), burn gauge =
assert.equal(y.motion, 'FLY');
const e = h.enemy('enemy_dummy');
assert.ok(h.runUntil(() => y.stats.attacks >= 1 && e.elem.burn > 0, 5));
// the hit's own 元素脆弱 (×1.2) lands before its burn fill
approx(e.elem.burn, 1000 * 0.2 * 1.2, 1e-6, 'gauge per hit');
// 元素脆弱 raises 元素伤害 only ("受到的元素伤害提升"), never the gauge fill (damage.js)
approx(e.elem.burn, 1000 * 0.2, 1e-6, 'gauge per hit');
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').length > 0, 20), 'burn burst');
const burst = h.hooksOf('damaged').find((c) => c.type === 'elemental' && c.target === e);
assert.ok(burst, 'elemental burst damage');
+73 -2
View File
@@ -8,7 +8,9 @@
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the live LP, the detail
// card order and the static game data; user playtest #4 (DESIGN §18): picking by the tile under the pointer and the
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑.
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑; user playtest #5
// (DESIGN §19): blocking by contact radius, 联防 forced exit, huge-boss hit areas and 自缚, the element pipeline rules,
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -21,7 +23,10 @@ import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE } from '../server/sim/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT } from '../server/sim/constants.js';
import { SELF_BOUND } from '../server/sim/content/bosses.js';
import * as BOARD from '../server/match/board.js';
import * as DAMAGE from '../server/sim/damage.js';
import { SUB } from '../server/sim/professions.js';
import { ENEMY_REACH } from '../public/js/render/pick.js';
import { BAND_TURN_SECONDS } from '../server/match/Match.js';
@@ -303,3 +308,69 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
assert.ok(!/ATK\/HP are \*\*replaced\*\*/.test(DATA_MD), 'DATA: 炎佑 stats are added (最终加算)');
assert.match(DESIGN, /with no enemy on the field it stays where it is/);
});
test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses, element pipeline, maps, phone camera — code and every doc agree', () => {
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
const S19 = sec(19);
assert.match(DESIGN, /## 19\. User playtest #5 \(v2\.4\)/);
for (let i = 1; i <= 9; i++) assert.match(S19, new RegExp(`### 19\\.${i} `), `§19.${i}`);
for (let i = 1; i <= 11; i++) assert.match(S19.slice(0, S19.indexOf('### 19.1')), new RegExp(`#${i} `), `the intro maps report #${i}`);
// #4 blocking: the contact radius (code = §5.5 = SIM)
assert.deepEqual({ ...BLOCK_RADIUS }, { ground: 0.70709997, fly: 0.8944, device: 0.4472 });
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
assert.match(s55, /ground 0\.7071/);
assert.match(s55, /takes the enemy over when its blocker dies, is withdrawn or is stunned/);
assert.ok(!/whose position enters its tile \(\|dx\|,\|dy\| ≤ 0\.5\)/.test(s55), '§5.5: the old same-tile block rule is gone');
assert.match(SIM, /taken over by another operator in contact with room, else they walk on/);
// #2 联防: carryState { down: true } → FORCED_EXIT (code = §5.1 / §5.5 / §6.1 / §18.3)
assert.equal(FORCED_EXIT, 'forcedExit');
assert.match(DESIGN, /carryState\?: \{ hpPct, sp, skillActive \} \| \{ down: true \}/);
assert.match(DESIGN, /or `FORCED_EXIT`, entering 联防 knocked out/);
assert.match(sec(6), /knocked out at the end of its own combat enters down/);
assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/);
// #10 huge bosses: hit areas (§3), 自缚 (§7)
assert.equal(SELF_BOUND.length, 7);
assert.match(sec(3), /data `hitArea`/);
assert.match(sec(3), /`sim\/body\.js`/);
assert.match(sec(3), /中点判定/);
assert.match(sec(7), /`content\/bosses\.js SELF_BOUND`/);
assert.match(PLAYING, /自缚、无法被阻挡/);
// #3 elements: one hasHp, the pipeline guard, 脆弱 vs 元素伤害, element healing per type
assert.equal(typeof DAMAGE.hasHp, 'function');
for (const f of ['server/sim/content/kits/tier5.js', 'server/sim/content/kits/tier6.js', 'server/sim/content/items/battle.js']) {
assert.ok(!/const hasHp = /.test(readFileSync(join(ROOT, f), 'utf8')), `${f}: no local hasHp copy`);
}
assert.match(s55, /元素伤害 takes 元素脆弱 \(`elementalTakenMul`\) alone, not `dmgTakenMul`/);
assert.match(SIM, /lowers element\s+`el`, or every element type, each by `amount` on its own/);
assert.match(PLAYING, /无来源伤害/);
// audit rows as corrected by the reviews
assert.match(S19, /卢西恩 ATK 700 \(solo 600\)/);
assert.match(S19, /T2 1\.5 % max HP of HP and element per s with ≥ 4 operators on the field \(elite ≥ 2/);
assert.match(S19, /特制水上平台 are ×64/);
assert.match(S19, /756×366 CSS px/);
assert.ok(!/1\.5\/2 % max HP|×68|830×381|1500 \/ 750/.test(S19), '§19: no refuted audit figure');
// #7 boss-field deploy: the board row shift has one name, the opposite of prepfield's
assert.equal(BOARD.BOARD_ROWS_ABOVE_BOSS, 7);
assert.equal(BOARD.BOSS_ROW_SHIFT, undefined, 'board.js: no BOSS_ROW_SHIFT (render/prepfield.js has the opposite sign)');
assert.match(PLAYING, /关底战场上的半场/);
// #9 phone prep camera: §18.1's v2.3 figures superseded
assert.match(sec(18), /superseded by §19\.8/);
assert.ok(!/71 % now/.test(DESIGN), 'DESIGN §18.1: the v2.3 notched-phone figure is not current');
assert.ok(!/71 % now/.test(readFileSync(join(ROOT, 'public/css/devices.css'), 'utf8')), 'devices.css: no stale figure');
// #1 / #5 / #4 player guide
assert.match(PLAYING, /\*\*立刻接替阻挡\*\*/);
assert.match(PLAYING, /召唤物在所有干员之后/);
assert.match(PLAYING, /近地悬浮/);
// integration QA residuals: blocked-first / out-of-range selection is melee-only (code = §5.5 = §19.2 = SIM = PLAYING);
// the forced-out timer is re-read after battleStart; the gauge intake's 5 % floor; the boss field's devices
assert.match(s55, /\(1\) the enemies it blocks \(melee units only, in range or not\)/);
assert.match(s55, /a ranged operator on a melee tile blocks but attacks only what its range holds/);
assert.ok(!/every blocker, ranged ones on melee tiles included/.test(DESIGN), '§19.2: the old [ASSUMED] is gone');
assert.match(SIM, /A ranged operator standing on a melee tile blocks, but targets by its range alone/);
assert.match(PLAYING, /站在近战位的远程干员也能阻挡/);
assert.match(s55, /after which the forced-out operators' timers are re-read/);
assert.match(SIM, /Right after `battleStart`\s+its timer is re-read/);
assert.match(s55, /max\(0\.05, 1 − 损伤抵抗\/100\)/);
assert.match(SIM, /max\(5 %, 1 − 损伤抵抗 \/ 100\)/);
assert.match(S19, /the boss field's devices are drawn with the boss field only/);
});
+3 -1
View File
@@ -746,7 +746,9 @@ async function observeAfterFinish(c) {
});
if (pen) {
assert.equal(pen.visible, 0, '返回战场: no enemy pen figure');
assert.ok(pen.band[1] <= 13 && (pen.area == null || pen.area <= 12), `返回战场: no pen rows / area (${JSON.stringify(pen)})`);
// the 3D area ends at the field's separator row 13 (its devices blow into the field: act2 m01's blowers, user
// playtest #5 item 6); the pen starts at row 14
assert.ok(pen.band[1] <= 13 && (pen.area == null || pen.area <= 13), `返回战场: no pen rows / area (${JSON.stringify(pen)})`);
}
}
c.note(`observed ${other.fieldId} after the own battle (${st.units} views) and went back`);
+18
View File
@@ -36,6 +36,24 @@ test('field model: the round\'s routes traced over the own board, weighted by th
m.dispose();
});
test('field model: 近地悬浮 enemies (掠海漂移体) walk the ground path but count as air units (no blocker credit, anti-air needed)', () => {
const h = soloBot({ seed: 2 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const groundRi = new Set(m.wave.routes.map((r, i) => (r.motion !== 'FLY' ? i : -1)).filter((i) => i >= 0));
const before = fieldModel(m);
assert.ok(before.ground.size > 0);
m.wave = { ...m.wave, spawns: m.wave.spawns.map((s) => (groundRi.has(s.routeIndex) && s.tag !== 'boss' ? { ...s, enemyKey: 'enemy_2025_syufo' } : s)) };
m._botPath = null;
const model = fieldModel(m);
const hover = m.wave.spawns.filter((s) => s.enemyKey === 'enemy_2025_syufo').reduce((a, s) => a + Math.max(1, s.count || 1), 0);
assert.ok(hover > 0);
assert.ok(model.flyTotal >= hover, `${model.flyTotal} ≥ ${hover}`);
const hov = model.routes.filter((r) => r.fly && r.tiles.some((k) => before.ground.has(k)));
assert.ok(hov.length > 0, 'an air route on the ground path tiles');
m.dispose();
});
test('layout planner: legal distinct tiles; blockers on the enemy road, ranged units cover it without standing on it', () => {
const h = soloBot({ seed: 3 }).start();
const m = h.m;
+5 -3
View File
@@ -166,7 +166,7 @@ test('联防: trigger, helpers (≤ 2, highest LP then seat), carry state, sourc
m.dispose();
});
test('联防: an operator dead at the end of the helper\'s own combat stays out (with its summons); the living carry their state', () => {
test('联防: an operator knocked out at the end of the helper\'s own combat is fielded `down` (强制退场, with its summons); the living carry their state', () => {
let deadUid = null;
const h = makeMatch({
mode: 'coop', humans: 2, seed: 37, fake: true,
@@ -194,8 +194,10 @@ test('联防: an operator dead at the end of the helper\'s own combat stays out
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1']);
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
const units = u.opts.players[0].units;
assert.ok(!units.some((x) => x.uid === hemo.uid), 'the dead operator is not redeployed at full HP');
assert.ok(!units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'nor are its summons');
// PRTS 卫戍协议/帮助: deployed, then "上一阶段为退场状态的干员强制退场" — the sim puts it down on its tile with its redeploy
// timer (user playtest #5 item 2: it used to be left out and vanished); its summons are fielded as the board has them
assert.deepEqual(units.find((x) => x.uid === hemo.uid)?.carryState, { down: true }, 'the knocked-out operator enters 联防 down');
assert.ok(units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'its summons are fielded');
assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
assert.ok(helper.board.has(`${t0[0]},${t0[1]}`) && helper.board.has(placed), 'the board itself is untouched (next round as usual)');
// during 联防 an 'n:<pid>' id names no field: refused, and the viewer keeps streaming the 联防 field
+1 -1
View File
@@ -173,7 +173,7 @@ function legacyInvariants(m) {
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
let deployed = 0;
const dmap = ps.deployMap();
const dmap = m.deployMapFor(ps); // a pure read (as server/match/invariants.js): no PlayerState cache touched
for (const [k, p] of ps.board) {
const [r, c] = k.split(',').map(Number);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1, `piece outside the board ${k}`);
+316
View File
@@ -0,0 +1,316 @@
// User playtest #5 item 7 — the Final Assault prep deploys on the player's half of the BOSS field (research 09 §1.2,
// official ConvertChessPositionInfoToBossMap / player_map_ud_offset 7): placement legality (server deploy map, the
// client's canPlace + legal-tile highlights, the bot) reads the boss field's tiles, not the normal field's.
// User: "这张关底boss图靠右侧由1*3的管子框起来的地块官方原版是能部署干员的" — act2autochess_m01's 1×3 orange-railed
// tiles (tile_fence_bound, LOW / buildable ALL) at boss (3–5, 8) and (3–5, 12) = board (10–12, 8) for both sides of a
// pair; on the normal field board (10–12, 8) is '#' (tile_forbidden), which the remake used to read in R14 too.
// Audit: for every stage and every deploy field the server map equals the builder's official deploy tiles
// (stages.json deployTiles.normal / bossLeft / bossRight, from the level files' buildableType / heightType + devices),
// the client mirror equals the server, and nothing legal on the normal board becomes illegal in the boss round.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDeployMap, canPlace, fieldTile, boardTileOf, tileKey, FIELD } from '../../server/match/board.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { ERR, PHASE } from '../../shared/constants.js';
import {
deployMap as clientDeployMap, deploySets, placementContext, canPlace as clientCanPlace, boardTargets, deployFieldOf,
fieldTile as clientFieldTile,
} from '../../public/js/ui/gameLogic.js';
import { planLayout } from '../../server/match/bot.js';
import { collectViolations } from '../../server/match/invariants.js';
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
const STAGE = 'act2autochess_m01';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const LEVELS = join(ROOT, '.cache', 'gamedata', 'levels', 'activities');
const levelFile = (id) => join(LEVELS, id.startsWith('act2') ? 'act2autochess' : 'act1autochess', `level_${id}.json`);
const HAS_LEVELS = Object.keys(DATA.stages).every((id) => existsSync(levelFile(id)));
const MELEE = (c) => c.position === 'MELEE';
const RANGED = (c) => c.position === 'RANGED';
const keysOf = (map, cls) => [...map].filter(([, v]) => !cls || v === cls).map(([k]) => k).sort();
/** stages.json deploy tiles of a boss half (field coordinates, rows 1–5) → board keys of deploy field `field`. */
const officialBoss = (st, field) => {
const dt = st.deployTiles[field === 'bossR' ? 'bossRight' : 'bossLeft'];
const toKey = ([r, c]) => { const [br, bc] = boardTileOf(field, r, c); return tileKey(br, bc); };
const inBoard = ([r, c]) => { const [br, bc] = boardTileOf(field, r, c); return br >= FIELD.r0 && br <= FIELD.r1 && bc >= FIELD.c0 && bc <= FIELD.c1; };
return { melee: dt.melee.filter(inBoard).map(toKey).sort(), ranged: dt.rangedOnly.filter(inBoard).map(toKey).sort() };
};
/** A 2-human co-op match on act2 m01 at the prep of round `round` (fake battles: nobody leaks). */
function coopAt(round, o = {}) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: o.seed ?? 21, fake: true }).start();
h.setStage(STAGE);
h.toPrep(round);
return h;
}
/** Empty the player's board and hand (deterministic scenario). */
function clean(ps) {
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
}
describe('#7 the Final Assault prep deploys on the boss field', () => {
test('repro: act2 m01 R14 — a melee operator may stand on the 1×3 fenced tiles (board col 8, rows 10–12), both sides', () => {
const h = coopAt(14);
const m = h.m;
assert.equal(m.round, m.gd.bossRound);
assert.equal(m.phase, PHASE.PREP);
const meleeIds = [1, 2, 3].flatMap((t) => chessOfTier(t, MELEE)).filter((id) => m.pool.has(id));
let next = 0;
for (const [pid, field, bossCol] of [['p_0', 'bossL', 8], ['p_1', 'bossR', 12]]) {
const ps = h.ps(pid);
clean(ps);
assert.equal(m.deployFieldOf(ps), field, `${pid} deploys on ${field}`);
for (const row of [10, 11, 12]) {
// what the player sees under that board tile: the boss field's fenced tile
const at = bossFieldPlacement(field === 'bossR' ? 'R' : 'L', row, 8);
assert.deepEqual([at.row, at.col], [row - 7, bossCol]);
assert.equal(DATA.stages[STAGE].rows[at.row][at.col], 'b', 'tile_fence_bound');
const a = give(m, ps, meleeIds[next++]);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: a.uid, to: { area: 'board', row, col: 8 } }), { ok: true }, `${pid} melee on board ${row},8`);
}
// the lane (floor, buildable NONE) stays closed on the boss field too
const b = give(m, ps, meleeIds[next++]);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: b.uid, to: { area: 'board', row: 10, col: 9 } }), { error: ERR.BAD_TILE });
}
checkInvariants(m);
});
test('a normal round still reads the normal field (board col 8 rows 10–12 is forbidden there)', () => {
const h = coopAt(13);
const m = h.m;
const ps = h.ps('p_0');
clean(ps);
assert.equal(m.deployFieldOf(ps), 'normal');
const a = give(m, ps, chessOfTier(1, MELEE).find((id) => m.pool.has(id)));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 11, col: 8 } }), { error: ERR.BAD_TILE });
});
test('the boss round keeps every piece placed in the normal rounds and the boss battle stands them on the boss tiles', () => {
const h = coopAt(13);
const m = h.m;
const ps = h.ps('p_0');
clean(ps);
const r1 = give(m, ps, chessOfTier(1, RANGED).find((id) => m.pool.has(id)));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: r1.uid, to: { area: 'board', row: 11, col: 4 } }), { ok: true }, 'high ground');
h.toPrep(14);
assert.equal(ps.board.get('11,4')?.uid, r1.uid, 'kept on its tile (normal ⊆ boss legality on every stage)');
const input = ps.battleInput({ side: 'L' });
assert.ok(input.units.some((u) => u.uid === r1.uid && u.row === 11 && u.col === 4), 'board coordinates in the battle input');
});
test('boss → boss: pieces on the fenced tiles in R14 stay there into the R15 (隐秘核心) prep, no terrain-change toast', () => {
// review finding: Match.startRound cleared bossWaves before PlayerState.startRound's recompute, so R15's first
// legality check read the NORMAL field and withdrew the pieces standing on board (10–12, 8)
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 52, fake: true, script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9 } : {}) }).start();
h.setStage(STAGE);
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${text}`); return toast(ps, kind, text); };
const fields = [];
m.deployFieldOf = ((orig) => function (ps) { const f = orig.call(this, ps); if (this.round === this.gd.hiddenRound) fields.push(f); return f; })(m.deployFieldOf);
const placed = {};
const meleeIds = chessOfTier(1, MELEE).filter((id) => m.pool.has(id));
for (const [i, pid] of ['p_0', 'p_1'].entries()) {
const ps = h.ps(pid);
clean(ps);
const a = give(m, ps, meleeIds[i]);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: a.uid, to: { area: 'board', row: 11, col: 8 } }), { ok: true });
placed[pid] = a.uid;
// Σ activated layers > 1200 (co-op) unlocks the hidden core after the win (finalAssault.test.js)
ps.bondCountBonus.yanShip = 3; ps.layers.yanShip = 601; ps.recompute();
}
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.hiddenRound);
assert.ok(fields.length > 0 && fields.every((f) => f === 'bossL' || f === 'bossR'), `R15 never reads the normal field (${[...new Set(fields)]})`);
for (const pid of ['p_0', 'p_1']) assert.equal(h.ps(pid).board.get('11,8')?.uid, placed[pid], `${pid} kept on the fenced tile in R15`);
assert.deepEqual(toasts.filter((t) => t.includes('地形变化')), []);
checkInvariants(m);
m.dispose();
});
test('re-pairing before the fight (a teammate quits) moves the right player onto the left half', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 5, fake: true }).start();
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossR');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossL', 'the odd player alone on the left half');
m.onLeave('p_0');
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossL');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossR');
// re-checked at once (Match._quit recomputes the players left), not at their next action
for (const [id, field] of [['p_1', 'bossL'], ['p_2', 'bossR']]) {
assert.equal(h.ps(id)._deployField, field, `${id} board re-checked on ${field}`);
assert.equal(h.ps(id)._legalityStale, false);
}
checkInvariants(m);
});
test('the invariant checker reads the deploy field without touching the player\'s cache', () => {
const h = coopAt(13);
const m = h.m;
const ps = h.ps('p_1');
ps.deployMap();
assert.equal(ps._deployField, 'normal');
m._planBossWaves(); // the boss pairing appears (as at R14's round start) before the player's next recompute
assert.equal(m.deployFieldOf(ps), 'bossR');
collectViolations(m);
checkInvariants(m);
assert.equal(ps._deployField, 'normal', 'collectViolations / checkInvariants are pure reads');
assert.equal(ps._legalityStale, false);
});
test('the bot plans boss-round layouts on the boss field (it may use the fenced tiles)', () => {
const h = coopAt(14);
const m = h.m;
const ps = h.ps('p_0');
clean(ps);
const ids = chessOfTier(2, MELEE).filter((id) => m.pool.has(id)).slice(0, 6);
const pieces = ids.map((id) => give(m, ps, id));
const plan = planLayout(m, ps, pieces);
const map = ps.deployMap();
for (const k of plan.values()) assert.equal(map.get(k), 'melee', `planned ${k} is a melee tile of the boss half`);
assert.equal(map.get('11,8'), 'melee');
});
});
describe('#7 audit: every stage × deploy field (server = official level data = client)', () => {
test('server deploy map on each field equals the builder\'s official deploy tiles', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const n = buildDeployMap(st);
assert.deepEqual(keysOf(n, 'melee'), st.deployTiles.normal.melee.map(([r, c]) => tileKey(r, c)).sort(), `${id} normal melee`);
assert.deepEqual(keysOf(n, 'ranged'), st.deployTiles.normal.rangedOnly.map(([r, c]) => tileKey(r, c)).sort(), `${id} normal ranged`);
for (const field of ['bossL', 'bossR']) {
const b = buildDeployMap(st, { field });
const off = officialBoss(st, field);
assert.deepEqual(keysOf(b, 'melee'), off.melee, `${id} ${field} melee`);
assert.deepEqual(keysOf(b, 'ranged'), off.ranged, `${id} ${field} ranged`);
}
}
});
test('client mirror equals the server on every field (with and without terrain overrides)', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const aliases = (st.devices || []).filter((d) => d.alias && (d.role === 'crate' || d.role === 'platform'));
const variants = [{}, { deviceOverrides: Object.fromEntries(aliases.map((d) => [d.alias, !d.active])) }];
for (const field of ['normal', 'bossL', 'bossR']) {
for (const ov of variants) {
const srv = buildDeployMap(st, { ...ov, field });
const cli = clientDeployMap(st, { ...ov, field });
assert.deepEqual([...cli.entries()].sort(), [...srv.entries()].sort(), `${id} ${field} ${JSON.stringify(ov).slice(0, 40)}`);
}
const sets = deploySets(st, field);
const srv = buildDeployMap(st, { field });
assert.deepEqual([...sets.melee].sort(), keysOf(srv, 'melee'), `${id} ${field} deploySets melee`);
assert.deepEqual([...sets.ranged].sort(), keysOf(srv).sort(), `${id} ${field} deploySets ranged`);
}
}
for (const f of ['normal', 'bossL', 'bossR']) for (const [r, c] of [[9, 2], [12, 10], [10, 8]]) assert.deepEqual(clientFieldTile(f, r, c), fieldTile(f, r, c));
});
test('independent check against the raw official level files (buildableType / heightType + non-hidden devices)', { skip: !HAS_LEVELS && 'no .cache/gamedata level files (run node tools/build-data.mjs once)' }, () => {
const ROLE = { trap_1105_accrate: 'block', trap_032_mound: 'block', trap_1106_achplat: 'platform' };
for (const [id, st] of Object.entries(DATA.stages)) {
const lv = JSON.parse(readFileSync(levelFile(id), 'utf8'));
const map = lv.mapData.map, tiles = lv.mapData.tiles, H = map.length;
const dev = new Map();
for (const t of lv.predefines?.tokenInsts || []) if (ROLE[t.inst?.characterKey] && !t.hidden) dev.set(`${t.position.row},${t.position.col}`, ROLE[t.inst.characterKey]);
const official = (r, c) => {
const t = tiles[map[H - 1 - r]?.[c]];
const d = dev.get(`${r},${c}`);
if (!t || d === 'block') return null;
// 射击台: ranged only [ASSUMED, DATA §15.11] (the remake's rule for a raised platform)
if (d === 'platform') return 'ranged';
if (t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE')) return 'melee';
if (t.buildableType === 'RANGED' || (t.heightType === 'HIGHLAND' && t.buildableType === 'ALL')) return 'ranged';
return null;
};
for (const field of ['normal', 'bossL', 'bossR']) {
const srv = buildDeployMap(st, { field });
for (let r = FIELD.r0; r <= FIELD.r1; r++) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
const [sr, sc] = fieldTile(field, r, c);
assert.equal(srv.get(tileKey(r, c)) ?? null, official(sr, sc), `${id} ${field} board ${r},${c} = stage ${sr},${sc}`);
}
}
// 联防: the second helper's board stands on the right half shifted by player_map_lr_offset 8 — the same classes
for (let r = FIELD.r0; r <= FIELD.r1; r++) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
if (official(r, c)) assert.equal(official(r, c + 8), official(r, c), `${id} 联防 right half ${r},${c + 8}`);
}
// benches: hand row 7 (boss row 0, the right half mirrored), temp row 8 cols 4–8 (boss row 1)
const key = (r, c) => tiles[map[H - 1 - r][c]].tileKey;
for (let i = 0; i < 10; i++) for (const [r, c] of [[7, i], [0, i], [0, 20 - i]]) assert.equal(key(r, c), 'tile_achand', `${id} hand ${r},${c}`);
for (let c = 4; c <= 8; c++) for (const [r, cc] of [[8, c], [1, c], [1, 20 - c]]) assert.equal(key(r, cc), 'tile_achand', `${id} temp ${r},${cc}`);
}
});
test('carry-over: a tile legal on the normal board is legal (same class) on both boss halves of every active stage', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const n = buildDeployMap(st);
for (const field of ['bossL', 'bossR']) {
const b = buildDeployMap(st, { field });
for (const [k, cls] of n) {
const [r, c] = k.split(',').map(Number);
if (cls === 'melee') assert.ok(canPlace(b, 'melee', r, c), `${id} ${field}: melee ${k}`);
else assert.ok(canPlace(b, 'ranged', r, c), `${id} ${field}: ranged ${k}`);
}
}
}
});
test('client placement: the boss prep lights and accepts the fenced tiles; the normal prep refuses them', () => {
const st = DATA.stages[STAGE];
const meleeId = chessOfTier(1, MELEE)[0];
const priv = { hand: [{ uid: 1, kind: 'chess', id: meleeId }, ...Array(9).fill(null)], temp: Array(5).fill(null), board: [], deployCap: 8, deployCount: 0 };
const getChess = (id) => DATA.chess[id];
for (const field of ['bossL', 'bossR']) {
const ctx = placementContext({ priv, stage: st, editable: true, field, getChess });
assert.equal(ctx.field, field);
for (const row of [10, 11, 12]) assert.equal(clientCanPlace(ctx, 1, { area: 'board', row, col: 8 }).ok, true, `${field} ${row},8`);
const lit = boardTargets(ctx, 1).legal.map(([r, c]) => tileKey(r, c));
assert.ok(['10,8', '11,8', '12,8'].every((k) => lit.includes(k)), `${field}: legal highlights ${lit}`);
}
const ctx = placementContext({ priv, stage: st, editable: true, getChess });
assert.equal(clientCanPlace(ctx, 1, { area: 'board', row: 11, col: 8 }).ok, false);
});
test('the DOM fallback board draws the boss half in the boss prep: the fenced tiles are floor, not walls', async () => {
// integration QA: with ?render=fallback (or no WebGL) the boss prep drew the normal field, whose board (10–12, 8) is
// '#' — highlighted as legal but drawn as walls
const { fallbackTileClass } = await import('../../public/js/ui/fallbackField.js');
const st = DATA.stages[STAGE];
for (const row of [10, 11, 12]) {
assert.equal(fallbackTileClass(st, row, 8), 'void', `normal field ${row},8: a wall`);
for (const field of ['bossL', 'bossR']) {
const [r, c] = clientFieldTile(field, row, 8);
assert.equal(fallbackTileClass(st, r, c), 'floor', `${field} board ${row},8 = boss ${r},${c}`);
}
}
const src = readFileSync(join(ROOT, 'public/js/ui/fallbackField.js'), 'utf8');
assert.match(src, /fieldTile\(st\.deployField, row, col\)/, 'the fallback maps its board tiles through fieldTile in prep');
assert.match(src, /st\.deployField = kind === 'bossPrep' \? \(o\?\.side === 'R' \? 'bossR' : 'bossL'\) : 'normal'/);
const game = readFileSync(join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /const prepCam = prepCamera\(pub, myId\);/, 'the match screen hands the fallback view the boss prep camera too');
});
test('deployFieldOf follows the boss-round pairing (alive players by seat, two by two)', () => {
const players = [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }, { playerId: 'c', seat: 2, alive: false }, { playerId: 'd', seat: 3 }];
const pub = { round: 14, bossRound: 14, hiddenRound: 15, players };
assert.equal(deployFieldOf(pub, 'a'), 'bossL');
assert.equal(deployFieldOf(pub, 'b'), 'bossR');
assert.equal(deployFieldOf(pub, 'd'), 'bossL');
assert.equal(deployFieldOf(pub, 'c'), 'normal', 'eliminated');
assert.equal(deployFieldOf({ ...pub, round: 15 }, 'b'), 'bossR', 'hidden core');
assert.equal(deployFieldOf({ ...pub, round: 13 }, 'b'), 'normal');
assert.equal(deployFieldOf(null, 'a'), 'normal');
});
});
+122
View File
@@ -0,0 +1,122 @@
// test/match/unitedown.test.js — user playtest #5 item 2: an operator knocked out (濒死) in a 联防 helper's own combat
// vanished in the 联防 phase instead of keeping its redeploy countdown and redeploying (unite.js left it out of the
// battle input). Official (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同
// (召唤物仅修改技力,上一阶段为退场状态的干员强制退场)" — it is deployed with everyone and forced out at once: down on its
// own tile with the redeploy ring, its timer running, and back like after any knock-out (tile free, DP ≥ cost).
// The match builds the input (both combat modes); the real sim runs it the way the authority, the display replicas
// (partner, observers) and the server fallback do.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { Battle } from '../../server/sim/Battle.js';
import { createBattleFromSpec } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DOWN_STATE, FORCED_EXIT } from '../../server/sim/constants.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, chessOfTier, legalTileFor, checkInvariants } from './harness.js';
/** A Battle that never ends by itself (the check needs the whole redeploy timer, whatever the enemies do). */
class NoFinish extends Battle {
constructor(o) { super({ ...o, autoFinish: false, timeLimit: 1e6 }); }
}
const downOf = (b, id) => (b.snapshot().down || []).find((d) => d[0] === id) || null;
/**
* p_0 leaks; p_1 and p_2 are perfect. p_1 (more units: the right-hand field) and p_2 each have an operator knocked out
* at the end of their own combat next to one that stands.
*/
function scenario(clientCombat) {
const dead = new Set();
const h = makeMatch({
mode: 'coop', humans: 3, seed: 3702, fake: true, clientCombat,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 3 }, deadUids: [...dead] } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const melee = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
const ranged = chessOfTier(1, (c) => c.position === 'RANGED').filter((x) => m.pool.has(x));
const place = (ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
const p1 = h.ps('p_1'), p2 = h.ps('p_2');
const down1 = place(p1, melee[0]);
const up1 = place(p1, ranged[0]);
place(p1, ranged[1]); // p_1 fields more units: the first helper, on the right-hand half
const down2 = place(p2, melee[1]);
const up2 = place(p2, ranged[2]);
dead.add(down1.uid);
dead.add(down2.uid);
h.drive(() => m.phase === PHASE.UNITE);
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1', 'p_2']);
return { h, m, down1, up1, down2, up2 };
}
/** The 联防 field's battle input and a real battle built from it, as each combat mode builds it. */
function uniteBattle(m, clientCombat) {
if (clientCombat) {
const f = m.fields[0];
assert.equal(f.fieldId, 'u');
// a browser (authority or display replica) builds the battle from the spec over its own copy of the data
return { players: f.spec.players, battle: createBattleFromSpec(f.spec, new DataSource(DATA, null), { BattleClass: NoFinish, recordEvents: true }) };
}
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
return { players: u.opts.players, battle: new NoFinish({ ...u.opts, data: m.ds, logger: { warn() {}, error() {}, info() {}, debug() {} } }) };
}
for (const clientCombat of [true, false]) {
test(`联防 (${clientCombat ? 'client-side combat' : 'server-run fallback'}): a helper's operator knocked out in its own combat enters down on its tile, counts down and redeploys`, () => {
const { h, m, down1, up1, down2, up2 } = scenario(clientCombat);
const { players, battle: b } = uniteBattle(m, clientCombat);
// the input: the knocked-out operators are fielded, flagged `down`; the standing ones carry HP% / SP
const inp = (pid, uid) => players.find((p) => p.playerId === pid).units.find((x) => x.uid === uid);
assert.deepEqual(inp('p_1', down1.uid)?.carryState, { down: true }, 'the right-hand helper\'s knocked-out operator is in the 联防 battle');
assert.deepEqual(inp('p_2', down2.uid)?.carryState, { down: true }, 'the left-hand helper\'s too');
assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
b.step();
const unitOf = (uid, pid) => b.allyUnits.find((u) => u.uid === uid && u.ownerId === pid);
for (const [piece, pid] of [[down1, 'p_1'], [down2, 'p_2']]) {
const u = unitOf(piece.uid, pid);
assert.ok(u, `${pid}: its unit exists`);
assert.ok(!u.alive && b.isDown(u), `${pid}: knocked out on the field from the start`);
const d = downOf(b, u.id);
assert.ok(d, `${pid}: in b.snap down (the redeploy ring)`);
assert.ok(Math.abs(d[2] - u.base.respawnTime) < 0.011, `${pid}: its full redeploy timer (${d[2]} s)`);
assert.equal(d[3], DOWN_STATE.COUNTING);
assert.ok(b.fieldMeta().units.some((x) => x.id === u.id), `${pid}: a client joining the field shows it down`);
assert.equal(u.removeReason, FORCED_EXIT);
}
const board1 = [...h.ps('p_1').board.entries()].find(([, p]) => p.uid === down1.uid)[0].split(',').map(Number);
const u1 = unitOf(down1.uid, 'p_1');
assert.deepEqual([u1.tileR, u1.tileC], [board1[0], board1[1] + 8], 'the first helper holds the right-hand half (colOffset +8), on its board tile');
assert.ok([unitOf(up1.uid, 'p_1'), unitOf(up2.uid, 'p_2')].every((u) => u.alive && u.deployed), 'the standing operators fight');
// redeploy: after the timer, on its own tile, at full HP (DP init 10 + 1/s ≥ cost by then)
for (const [piece, pid] of [[down1, 'p_1'], [down2, 'p_2']]) {
const u = unitOf(piece.uid, pid);
const home = [u.homeR, u.homeC];
while (b.time < u.respawnAt + 0.2 && !u.alive) b.step();
assert.ok(u.alive && u.deployed, `${pid}: redeployed at ${b.time.toFixed(2)} s (respawnAt ${u.respawnAt.toFixed(2)})`);
assert.deepEqual([u.tileR, u.tileC], home, `${pid}: on its own tile`);
assert.ok(Math.abs(u.hp - u.s.maxHp) < 1e-6, `${pid}: full HP on redeploy (${u.hp} / ${u.s.maxHp})`);
assert.equal(downOf(b, u.id), null);
}
checkInvariants(m);
m.dispose();
});
}
test('the docs describe the rule (docs/META.md §4, docs/SIM.md §1.1 / §9, docs/PLAYING.md §5)', async () => {
const { readFileSync } = await import('node:fs');
const read = (f) => readFileSync(new URL(`../../docs/${f}`, import.meta.url), 'utf8');
const META = read('META.md'), SIM = read('SIM.md'), PLAYING = read('PLAYING.md');
assert.match(META, /knocked out at the end of the helper's own\s+combat carries `\{ down: true \}`/);
assert.ok(!/stays out of the 联防 battle/.test(META), 'META: the old "stays out" rule is gone');
assert.ok(!/do not take part in the 联防 battle/.test(META), 'META §7: the old assumption is gone');
assert.match(META, /knocked out at the end of its own combat is deployed and forced out at once/);
assert.match(SIM, /`carryState: \{ down: true \}`/);
assert.match(SIM, /reason `'killed'` or `FORCED_EXIT`/);
assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/);
assert.ok(!/已倒下的干员不参加/.test(PLAYING));
});
+5 -4
View File
@@ -134,9 +134,10 @@ describe('3D board in headless Chrome', { skip }, () => {
assert.deepEqual(problems, []);
assert.equal(keys[0], keys[3], 'back to the normal area');
// user playtest #2 item 6: the prep board no longer builds the enemy pen block (only the pen camera does), so the
// union while flying is the prep field + the boss field
assert.equal(keys[0], '6,12,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,12,0,10', `union while flying: ${keys[1]}`);
// union while flying is the prep field + the boss field; the field takes its separator row 13 (user playtest #5
// item 6: act2 m01's blowers stand on it)
assert.equal(keys[0], '6,13,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,13,0,10', `union while flying: ${keys[1]}`);
assert.equal(keys[2], '0,6,0,20');
});
@@ -178,7 +179,7 @@ describe('3D board in headless Chrome', { skip }, () => {
assert.equal(r.faArea, '0,6,0,20', 'FA prep builds the boss field');
assert.deepEqual(r.faBand, [0, 13]);
assert.ok(r.faCamTx > 10, `right-hand boss prep camera (tx ${r.faCamTx})`);
assert.equal(r.prepArea, '6,12,0,10', 'prep board without the pen block (user playtest #2 item 6)');
assert.equal(r.prepArea, '6,13,0,10', 'prep board without the pen block (user playtest #2 item 6), with the row-13 wall');
assert.equal(r.bossArea, '0,6,0,20');
assert.equal(r.devices, 2);
assert.equal(r.attached, 2, 'both crate meshes still in the scene after the flight');
+89
View File
@@ -0,0 +1,89 @@
// test/render/bossbody.browser.test.js — user playtest #5 item 10 in headless Chrome through the render demo (real
// Spine models, the official boss camera): a huge boss (假想敌:胄, data/enemies.json `hitArea` 4.95 × 2.95 up 1 — the
// sim's hit rectangle, server/sim/body.js) is picked by a press anywhere on its hit area — the 5 × 3 tiles under its
// drawn body — while an operator standing on a tile inside that area is still picked on its tile, and a press beside
// the area picks nothing (render/pick.js AREA_PICK).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/bossbody.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/bossbody.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
/** Page side: the boss field of 源石流发生装置 with 假想敌:胄 at (3,10), an operator on the fence (4,8), a regular enemy. */
function installBoss() {
const v = window.__demo.view;
const rect = { r0: 0, r1: 5, c0: 0, c1: 20 };
const units = [
{ id: 1, kind: 'op', side: 'ally', defId: 'char_102_texas', spine: 'char_102_texas', avatar: 'char_102_texas', x: 8, y: 4, maxHp: 1000, facing: 1, dir: 'RIGHT', tier: 3 },
{ id: 2, kind: 'enemy', side: 'enemy', defId: 'enemy_9013_acstmk', spine: 'enemy_9013_acstmk', avatar: 'enemy_9013_acstmk', x: 10, y: 3, maxHp: 600000, facing: -1, boss: true },
{ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1000_gopro_2', spine: 'enemy_1000_gopro_2', avatar: 'enemy_1000_gopro_2', x: 15, y: 2, maxHp: 1000, facing: -1 },
];
v.enterBattle({ fieldId: 'bb', kind: 'boss', rect, stageId: 'act2autochess_m01', units });
v.setCamera('boss', { rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const tup = (u) => [u.id, u.x, u.y, u.maxHp, u.maxHp, 0, 0, 0, 0];
window.__boss = {
step(gt) { v.pushSnapshot({ t: 'b.snap', fieldId: 'bb', gt, units: units.map(tup), dp: 5, killed: 0, total: 3 }); },
/** The unit id picked by a press on a spot of tile (row, col) (u, v ∈ [−0.5, 0.5] across / along it). */
pickAt(row, col) {
const t = v.tileScreen(row, col), r = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - r.left, t.y - r.top)?.id ?? null;
},
};
}
describe('a huge boss is picked anywhere on its hit area (headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('the 5 × 3 body picks the boss; the operator on the fence tile inside it and the tiles beside it do not', async () => {
const page = await browser.newPage();
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('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installBoss);
const feed = (from, to) => page.evaluate(async (from, to) => {
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) { window.__boss.step(+gt.toFixed(4)); await new Promise((r) => requestAnimationFrame(() => r())); }
}, from, to);
await feed(1, 2);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await feed(2, 3);
await page.screenshot({ path: path.join(OUT, 'bossbody.png') });
const pick = (r, c) => page.evaluate((r, c) => window.__boss.pickAt(r, c), r, c);
for (const [r, c] of [[3, 10], [4, 9], [5, 11], [5, 12], [3, 12], [4, 11], [3, 8], [5, 8]]) assert.equal(await pick(r, c), 2, `(${r},${c}) on the boss body`);
assert.equal(await pick(4, 8), 1, 'the operator on its fence tile inside the area');
for (const [r, c] of [[3, 14], [2, 7], [4, 6]]) assert.equal(await pick(r, c), null, `(${r},${c}) beside the body`);
assert.equal(await pick(2, 15), 3, 'a regular enemy on its tile');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+25 -1
View File
@@ -1,13 +1,15 @@
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js).
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js). User playtest #5
// item 2: an operator entering 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot, TUPLE } from '../../public/js/render/interp.js';
import { FORCED_EXIT as SIM_FORCED_EXIT } from '../../server/sim/constants.js';
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
before(async () => {
@@ -102,6 +104,28 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.equal(v._downRing.text.text, '18');
});
test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => {
const { FORCED_EXIT, showsDeathFx } = await import('../../public/js/render/app.js');
assert.equal(FORCED_EXIT, SIM_FORCED_EXIT, 'the renderer mirrors the sim reason');
const info = { side: 'ally', kind: 'chess' };
assert.equal(showsDeathFx(info, false, FORCED_EXIT), false, 'no death particles');
assert.equal(showsDeathFx(info, false, 'killed'), true, 'a knock-out keeps them');
const v = view();
frames(v, 2);
v.die(true); // b.ev ['die', id, 'forcedExit'] (render/app.js handleEvent)
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30, 'no fall: the end of the Die clip');
v.setDown([1, 70, 70, DOWN_STATE.COUNTING], 0.03); // b.snap down, same frame
frames(v, 240, 1 / 60, () => 2);
assert.equal(v.remove, false, 'stays on its tile');
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '68');
const w = view({ id: 2 });
frames(w, 2);
w.die();
assert.ok(w.dieT < 1, 'an ordinary death still plays the clip from its start');
});
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
+102
View File
@@ -0,0 +1,102 @@
// test/render/forms.browser.test.js — user playtest #5 item 1 in headless Chrome: a 掠海漂移体 hovering down act2 m01's
// lower lane is stunned, drops to 爬行模式 for good (sim content/enemies.js kitSyufo: a ground unit from then on, blocked
// and hit by 山), and its view follows — the real sim runs in the page (the client runner's loadBrowserSim) and feeds the
// render demo's field view: before the drop the hover clips (*_01), on the drop 'Change', then the crawl clips (*_02)
// while 山 blocks it (render/app.js fx 'phase' → render/units.js setForm / FORMS). It used to keep hovering.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/forms.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/forms-syufo-crawl.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// 山 on (9,8) facing the gate; one tanky, harmless 掠海漂移体 walking the lower lane (9,10) → (9,2)
const SPEC = buildBattleSpec({
battleId: 'e2e.forms', fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 12, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_5_17_a', row: 9, col: 8, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
spawns: [{ time: 0, enemyKey: 'enemy_2025_syufo', routeIndex: 0, count: 1, mods: { hpMul: 50, atkMul: 0.01 } }],
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
});
describe('掠海漂移体 drops to 爬行模式: its model crawls (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('hover clips before the stun, \'Change\' on the drop, then the crawl clips while 山 blocks and hits it', async () => {
const page = await browser.newPage();
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('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const log = await page.evaluate(async (spec) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const out = [];
let stunAt = null;
for (let i = 0; i < 30 * 14; i++) {
b.step();
const e = b.enemies.find((x) => x.defId === 'enemy_2025_syufo');
if (stunAt == null && e && b.time >= 6) { stunAt = b.time; b.applyStatus(e, 'stun', { duration: 1, source: null }); }
if (i % 3 === 2) {
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
if (i === 30 * 3) await new Promise((r) => setTimeout(r, 2000)); // the Spine models load
const view = e && v.debug.views.get(e.id);
if (e && i % 6 === 5) out.push({ t: +b.time.toFixed(2), flying: e.isFlying, blocked: !!e.blockedBy, clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady });
}
}
return { out, stunAt };
}, SPEC);
await page.screenshot({ path: path.join(OUT, 'forms-syufo-crawl.png') });
const before = log.out.filter((x) => x.spine && x.t < log.stunAt);
assert.ok(before.length > 0, 'the model loaded before the stun');
assert.ok(before.every((x) => x.flying && /_01$/.test(x.clip)), `hovering: the *_01 clips (${[...new Set(before.map((x) => x.clip))]})`);
const after = log.out.filter((x) => x.t > log.stunAt);
assert.ok(after.some((x) => x.clip === 'Change'), `the drop plays 'Change' (${[...new Set(after.map((x) => x.clip))]})`);
const late = after.filter((x) => x.t > log.stunAt + 3);
assert.ok(late.length > 0 && late.every((x) => !x.flying && x.form === 'crawl' && /_02$/.test(x.clip)), `crawling: the *_02 clips (${[...new Set(late.map((x) => x.clip))]})`);
assert.ok(late.some((x) => x.blocked), '山 blocks it once it crawls');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+122
View File
@@ -0,0 +1,122 @@
// test/render/forms.test.js — user playtest #5 item 1, client side: 掠海漂移体 drops from 近地悬浮 to 爬行模式 for good
// when stunned / frozen / put to sleep (sim content/enemies.js kitSyufo, fx 'phase' { id, kind: 'crawl' }); from then on
// melee operators block and hit it, so its model must stop hovering: render/units.js FORMS switches the view to the
// skeleton's crawl clips (*_02) after its 'Change' clip (render/spine.js SpineActor.setForm), also for a view built
// after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js).
import { test, describe, before, after } 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 { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const SYUFO = 'enemy_2025_syufo';
let fake, UnitView, FORMS;
before(async () => {
fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
/** Asset store with the real manifest entry of the skeleton and its animation names. */
function store(id) {
const entry = assets.enemies[id].spine;
const names = Object.keys(entry.animations || {});
return {
picture: () => null,
image: async () => null,
spineEntry: () => entry,
spine: { acquire: async () => ({ animations: names.map((name) => ({ name })) }), release() {} },
};
}
async function syufo(info = {}) {
const ctx = fakeViewCtx(fake.P, { assets: store(SYUFO), cam });
const v = new UnitView(ctx, { id: 7, side: 'enemy', kind: 'enemy', defId: SYUFO, spine: SYUFO, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
await tick(); await tick();
assert.ok(v.actor, 'Spine model built');
return v;
}
const clip = (v) => v.actor.current;
const sample = (flags = 0, anim = 0) => ({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags, anim, vx: 0 });
const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); };
describe('掠海漂移体 爬行模式 (FORMS)', () => {
test('the crawl set exists in the skeleton the manifest lists', () => {
const f = FORMS[SYUFO].crawl;
const anims = assets.enemies[SYUFO].spine.animations;
for (const name of [f.change, f.roles.idle, f.roles.die, f.roles.move.loop, f.roles.attack.loop]) assert.ok(name in anims, name);
assert.equal(assets.enemies[SYUFO].spine.anims.idle, 'Idle_01', 'the manifest keeps the hover set as the default');
});
test('hovering: the hover clips; on the change: \'Change\' once, then the crawl clips (idle, move, attack, die)', async () => {
const v = await syufo();
v.sync(sample(), 1);
assert.equal(clip(v), 'Idle_01');
v.setForm('crawl');
assert.equal(clip(v), 'Change', 'the transition first');
frames(v, 50); // Change is 0.667 s
assert.equal(clip(v), 'Idle_02', 'crawling idle');
v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), 'Move_02');
v.onAttack?.(null, 2.1);
assert.equal(clip(v), 'Attack_02');
v.die();
assert.equal(clip(v), 'Die_02');
});
test('stunned when it drops (the usual case): the change plays out, then the stun holds, then it crawls', async () => {
const v = await syufo();
v.sync(sample(UF.STUNNED), 1);
assert.equal(v.actor.mode, 'stun');
v.setForm('crawl');
assert.equal(clip(v), 'Change');
frames(v, 10);
v.sync(sample(UF.STUNNED), 1.2); // still stunned: the change is not cut short
assert.equal(v.actor.mode, 'change');
frames(v, 40);
assert.equal(v.actor.mode, 'stun', 'then the stun holds (its pose frozen)');
v.sync(sample(0), 3);
assert.equal(clip(v), 'Idle_02', 'the stun over: crawling');
});
test('a view built after the change (culled, rejoined) starts on the crawl set without replaying \'Change\'', async () => {
const v = await syufo({ form: 'crawl' });
assert.equal(v.form, 'crawl');
v.sync(sample(), 1);
assert.equal(clip(v), 'Idle_02');
const w = await syufo({ form: 'crawl' });
w.die();
assert.equal(clip(w), 'Die_02');
});
test('modes without a clip set change nothing; the same mode twice is a no-op', async () => {
const v = await syufo();
v.setForm('grounded');
assert.equal(clip(v), 'Idle_01');
v.setForm('crawl');
frames(v, 50);
v.setForm('crawl');
assert.equal(clip(v), 'Idle_02', 'no second Change');
const ctx = fakeViewCtx(fake.P, { assets: store('enemy_10045_parrot'), cam });
const p = new UnitView(ctx, { id: 8, side: 'enemy', kind: 'enemy', defId: 'enemy_10045_parrot', spine: 'enemy_10045_parrot', tier: 1, x: 8, y: 9, maxHp: 1000 });
await tick(); await tick();
p.setForm('grounded');
p.setForm('float');
assert.equal(clip(p), 'Idle_A', '吉兆飞鳞 keeps its own clips (its 晕眩模式 is the Stun clip)');
});
});
test('render/app.js hands the sim\'s fx \'phase\' to the view and keeps the mode on the unit info', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /e\[1\] === 'phase'[\s\S]{0,300}inf\.form = [\s\S]{0,200}setForm\?\.\(e\[4\]\.kind\)/);
});
+2 -1
View File
@@ -451,7 +451,8 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
const slot = await tileSpot(page, 7, 5);
// the first point below the slot's centre that the shop bar covers (it overlaps the lower half of bench slots 4–9)
// the first point below the slot's centre that the shop bar covers (it starts right below the bench — the prep
// camera keeps the bench clear of it, user playtest #5 item 9; before, it overlapped the lower half of slots 4–9)
const bar = await page.evaluate((p) => {
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
+29 -1
View File
@@ -8,7 +8,8 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { presetCamera, pickTile } from '../../public/js/render/projection.js';
import { pickOnTile, pickBattle, ENEMY_REACH } from '../../public/js/render/pick.js';
import { pickOnTile, pickBattle, ENEMY_REACH, AREA_PICK, hitRectAt } from '../../public/js/render/pick.js';
import { hitRect } from '../../server/sim/body.js';
import { unitDepthKey } from '../../public/js/render/units.js';
import { TILE_H } from '../../public/js/render/style.js';
import { GEO } from '../../shared/constants.js';
@@ -152,6 +153,33 @@ describe('pickBattle: allies by their tile, walking enemies by their ground posi
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
});
test('a huge boss (user playtest #5 item 10): a press anywhere on its hit area picks it; allies on their tile and nearer enemies win', () => {
// 假想敌:胄 at (3,10): 4.95 × 2.95 up 1 → rows 3–5 × cols 8–12 (the sim's rectangle, server/sim/body.js)
const HA = { w: 4.95, h: 2.95, dx: 0, dy: 1 };
const area = hitRectAt(10, 3, HA);
assert.deepEqual(area, hitRect({ x: 10, y: 3, hitArea: HA }), 'the same rectangle as the sim');
assert.equal(hitRectAt(10, 3, null), null);
const boss = { key: 'boss', tile: null, x: 10, y: 3, area, body: { x: 600, top: 600 - 3 * 90, feet: 600, s: 90 } };
const far = [2000, 2000]; // pointer far from its drawn body line on screen: only the ground area counts
for (const [x, y] of [[8, 3], [12.4, 5.4], [7.6, 4], [10, 5]]) assert.equal(pickBattle([boss], at(x, y), ...far)?.key, 'boss', `(${x},${y}) on the area`);
for (const [x, y] of [[7.4, 4], [10, 5.6], [9, 2.3], [12.6, 3]]) assert.equal(pickBattle([boss], at(x, y), ...far), null, `(${x},${y}) off the area`);
const A = ally('A', 4, 8), e = foe('e', 11, 4.2);
assert.equal(pickBattle([boss, A], at(8.3, 4.3), ...far).key, 'A', 'an ally on the fence tile inside the area');
assert.equal(pickBattle([boss, A], at(9.2, 4.3), ...far).key, 'boss', 'the next tile: the boss');
assert.equal(pickBattle([boss, e], at(11.2, 4.1), ...far).key, 'e', 'a regular enemy near the pointer');
assert.ok(AREA_PICK > Math.SQRT1_2 && AREA_PICK > ENEMY_REACH);
// a flying unit never uses a ground area
assert.equal(pickBattle([{ ...boss, fly: true }], at(9, 4), ...far), null);
// the drawn body: an upright box from its feet to its head, hw tiles either side (its px per tile) — off the grid
// too
const big = { ...boss, body: { ...boss.body, hw: 2.475 } };
assert.equal(pickBattle([big], null, 600 + 2.2 * 90, 600 - 2.8 * 90)?.key, 'boss', 'its shoulder, far from the centre line');
assert.equal(pickBattle([boss], null, 600 + 2.2 * 90, 600 - 2.8 * 90), null, 'without hw: the centre line only');
assert.equal(pickBattle([big], null, 600 + 2.6 * 90, 600 - 1 * 90), null, 'beside the box');
assert.equal(pickBattle([big], null, 600 + 1.5 * 90, 600 - 3.8 * 90), null, 'above its head (beyond the line reach too)');
assert.equal(pickBattle([big, A], at(8.1, 4.1), 600 - 2 * 90, 600 - 1 * 90).key, 'A', 'the ally on its tile inside the box');
});
test('bad input: nothing', () => {
assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
+144
View File
@@ -0,0 +1,144 @@
// User playtest #5 item 6 — "有两个吹风的风道确实做出来了,但是吹风机的贴图丢失了,反而在关底吹风机的贴图又出现了":
// act2autochess_m01 (战场#05(下半) 源石流发生装置) has 8 blowers (trap_013_blower, dir DOWN): the normal field's on the
// separator row 13 (cols 5 / 9, the partner's 13 / 17) blowing into rows 12–10, the boss field's on row 6 (cols 5 / 9 /
// 11 / 15) blowing into rows 5–3. The 3D board's normal / 联防 area stopped at row 12, so the normal field's blowers
// were never built (their airflow streaks — Pixi — still showed), while the boss field's were (the Final Assault view
// and — facing away, under the bench on the row-6 wall — the normal view).
// Now: the normal / 联防 areas take the separator row 13, and every active device standing on a built tile is drawn
// (render/board3d/layout.js stageDevices; the 2D board render/tiles.js _stageDevices follows the same rule) — except
// the boss field's (rows ≤ 6, its top wall under the bench included), drawn with the boss field only: the official
// normal cameras never show that wall (under the shop bar in prep, off-screen in battle), and the remake's prep view
// showed the boss round's machines under the bench ("反而在关底吹风机的贴图又出现了", QA residual of the integration).
// Audit: for every stage and view the 3D board and the 2D board draw the same devices, and every active device is
// drawn exactly in the views whose area holds its tile (the boss field's only in the boss views).
import { test, describe } 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 { buildBoard, stageDevices, classifyStage, AREAS, BOSS_WALL_ROW } from '../../public/js/render/board3d/layout.js';
import { boardArea, bandFor, fieldRows, viewKind } from '../../public/js/render/app.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const M01 = stages.act2autochess_m01;
const devicesIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.map((d) => `${d.kind}@${d.r},${d.c}`).sort();
const blowersIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
/**
* Inside the 3D area of a view (the 2D board also shows the partner's half of rows 9–12 as a display in the own views,
* with its devices; the 3D board builds cols 0–10 there — plus the pen block, whose front wall holds the partner's
* blower (13,13) in the pen view).
*/
const inArea = (vk) => (k) => {
const [r, c] = k.split('@').pop().split(',').map(Number);
return boardArea(vk).some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
};
/** The 2D board's device list for a view kind, within that view's 3D area (external mode: no Pixi meshes needed). */
async function devicesIn2d(st, vk) {
const { installFakePixi } = await import('./fakepixi.js');
const fake = installFakePixi();
try {
const P = globalThis.PIXI;
P.BLEND_MODES = { ADD: 1, NORMAL: 0 };
const { TileField } = await import('../../public/js/render/tiles.js');
const layers = { ground: new P.Container(), overlay: new P.Container(), props: new P.Container(), anim: new P.Container() };
for (const l of Object.values(layers)) l.addChild = function (...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } return kids[0]; };
const f = new TileField(layers);
f.setExternal(true);
f.setView(bandFor(vk), null, fieldRows(vk));
f.setStage(st);
const out = f._stageDevices().map((d) => `${d.role}@${d.pos[0]},${d.pos[1]}`).filter(inArea(vk)).sort();
const flow = f.blowers.map((b) => `${b.row},${b.col}`).filter(inArea(vk)).sort();
f.destroy();
return { devices: out, flow };
} finally {
fake.restore();
}
}
const VIEWS = ['prep', 'normal', 'unite', 'bossPrep', 'boss', 'pen'];
describe('#6 act2 m01 blowers in every view', () => {
test('repro: the normal / prep view builds the own field\'s row-13 blowers (cols 5, 9) above the wind lanes — and not the boss field\'s row-6 machines under the bench', () => {
for (const vk of ['prep', 'normal']) assert.deepEqual(blowersIn3d(M01, vk), ['13,5', '13,9'], vk);
});
test('联防 builds both halves\' row-13 blowers; the Final Assault builds the four row-6 blowers', () => {
assert.deepEqual(blowersIn3d(M01, 'unite'), ['13,13', '13,17', '13,5', '13,9']);
for (const vk of ['boss', 'bossPrep', viewKind('hidden'), viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })]) {
assert.deepEqual(blowersIn3d(M01, vk), ['6,11', '6,15', '6,5', '6,9'], vk);
}
});
test('the blower machines stand on the separator blocks; the row-13 ones blow onto the field below them', () => {
const b = buildBoard(M01, { area: boardArea('prep') });
for (const d of b.devices.filter((x) => x.kind === 'blower')) {
const t = b.grid[d.r][d.c];
assert.equal(t.glyph, 'X');
assert.ok(t.drawn && d.z0 === t.h && t.h > 0, 'on top of the drawn separator block');
assert.equal(d.dir, 'DOWN');
// row 13: onto the built field's lanes (rows 12–10)
assert.equal(d.r, 13, `${d.r},${d.c}: only the field's own machines`);
const onto = d.rangeTiles.slice(1).map(([r, c]) => !!b.grid[r][c].content);
assert.deepEqual(onto, [true, true, true], `${d.r},${d.c}`);
}
// the separator row is built along the field's top edge (the pen rows 14–18 are still not)
const rows = new Set(b.grid.flat().filter((t) => t.drawn).map((t) => t.r));
assert.ok(rows.has(13) && !rows.has(14) && !rows.has(5), [...rows].join());
});
test('the 2D board draws the same blowers and airflow per view', async () => {
for (const vk of VIEWS) {
const d2 = await devicesIn2d(M01, vk);
assert.deepEqual(d2.flow, blowersIn3d(M01, vk), `${vk}: airflow streaks = built blowers`);
}
});
});
describe('#6 audit: every device of every stage is drawn in the views of the field it belongs to', () => {
test('3D board = 2D board for every stage and view', async () => {
for (const [id, st] of Object.entries(stages)) {
for (const vk of VIEWS) {
const d3 = devicesIn3d(st, vk);
const d2 = (await devicesIn2d(st, vk)).devices;
assert.deepEqual(d3, d2, `${id} ${vk}`);
}
}
});
test('every active device standing on the built island of a view is drawn — every field device and every device acting on the field among them', () => {
const KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
const inRect = (r, c, R) => r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1;
// the fields inside their separator walls (normal / 联防: rows 7–12; boss: rows 0–5); the boss field's devices
// (rows ≤ 6: its top wall under the bench too) are drawn in the boss views only
const INNER = { prep: { r0: 7, r1: 12, c0: 0, c1: 10 }, unite: { r0: 7, r1: 12, c0: 0, c1: 20 }, boss: { r0: 0, r1: 5, c0: 0, c1: 20 } };
for (const [id, st] of Object.entries(stages)) {
for (const [vk, F] of Object.entries(INNER)) {
const b = buildBoard(st, { area: boardArea(vk) });
const built = new Set(b.devices.map((d) => `${d.kind}@${d.r},${d.c}`));
for (const d of st.devices || []) {
if (!KINDS.has(d.role) || !(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
const [r, c] = d.pos;
const key = `${d.role}@${r},${c}`;
const onIsland = boardArea(vk).some((a) => inRect(r, c, a)) && !!b.grid[r]?.[c]?.drawn && (vk === 'boss' || r > BOSS_WALL_ROW);
const acts = (d.rangeTiles || []).some(([rr, cc]) => (rr !== r || cc !== c) && inRect(rr, cc, F) && b.grid[rr]?.[cc]?.content);
assert.equal(built.has(key), onIsland, `${id} ${vk}: ${key} (${d.alias || d.key}) drawn iff on the built island`);
if (inRect(r, c, F) || acts) assert.ok(built.has(key), `${id} ${vk}: ${key} of this field drawn`);
}
}
}
});
test('stageDevices keeps every active device on a drawn tile — the boss field\'s only when the area builds the boss field', () => {
const blowers = (area) => stageDevices(M01, classifyStage(M01, area), area).filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
assert.deepEqual(blowers(AREAS.boss), ['6,11', '6,15', '6,5', '6,9']);
assert.deepEqual(blowers(AREAS.normal), ['13,13', '13,5', '13,9'], 'the pen block\'s front wall holds the partner\'s (13,13); the row-6 wall under the bench none');
assert.deepEqual(blowers([...AREAS.boss, ...AREAS.normal]), ['13,13', '13,5', '13,9', '6,11', '6,15', '6,5', '6,9'], 'a camera flight between the fields: both');
assert.equal(blowers(AREAS.all).length, 8, 'the whole map: all 8');
assert.equal(stageDevices(M01, classifyStage(M01, null)).filter((d) => d.kind === 'blower').length, 8, 'no area: the whole map');
});
});
+135 -1
View File
@@ -7,7 +7,7 @@ import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
Camera, fitCamera, presetCamera, lerpCamera, pickTile, tileQuad, normRect, CAMERA_PRESETS, easeInOutCubic,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera, clearHud,
} from '../../public/js/render/projection.js';
import * as THREE from 'three';
@@ -247,6 +247,140 @@ describe('pickTile / tileQuad', () => {
});
});
describe('HUD clearance of the prep views (user playtest #5 item 9)', () => {
// the in-match HUD in rem (ui/fieldHost.js HUD_REM): the bond strip ends 2.16rem below the top, the shop bar starts
// 2.64rem + 3 px above the bottom; the root font size is clamp(40px, min(W / 19.2, H / 10.8), 240px) (css/theme.css)
const hudAt = (w, h) => {
const rem = Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
return { top: 2.16 * rem, bottom: 2.64 * rem + 3 };
};
const band = (cam, kind) => {
const k = CAMERA_PRESETS[kind].keep;
return { near: cam.project(cam.tx, k.near, k.zNear).y, far: cam.project(cam.tx, k.far, k.zFar).y };
};
// phones in landscape (CSS px): iPhone 12–15 (19.5:9), iPhone Pro Max, Galaxy S20+ / 2400×1080 (20:9), 800×360,
// Xperia 21:9, 924×424, 740×360, and the user's Android: its screenshot is 2772×1272 px with the page drawn right of
// a 141 px black cutout band, i.e. 2631×1272 px at DPR ≈ 3.48 → 756×366 (its exit and 准备就绪 buttons match
// to ±1 px there), and 798×366 if the page also covered the cutout
const PHONES = [[844, 390], [932, 430], [915, 412], [914, 411], [800, 360], [960, 411], [756, 366], [798, 366], [924, 424], [740, 360]];
const DESKTOPS = [[1920, 1080], [1680, 1050], [1280, 720], [1366, 768], [1440, 900], [1536, 864], [1600, 900], [2560, 1440],
[3840, 2160], [2560, 1080], [3440, 1440], [1600, 1200], [1180, 820], [1024, 768], [1920, 1200]];
const cams = (kind, w, h, hud) => (kind === 'bossPrep' ? ['L', 'R'] : ['L']).map((side) => [side, presetCamera(kind, { width: w, height: h }, { side, hud })]);
test('reproduction: the official shop camera puts the bench under the shop bar on phones (844×390: ~11 px, 800×360: ~19 px, 756×366 as on the user\'s Android: ~17 px)', () => {
for (const [w, h] of [[844, 390], [800, 360], [756, 366]]) {
for (const kind of ['prep', 'bossPrep']) {
const b = band(presetCamera(kind, { width: w, height: h }), kind);
assert.ok(b.near > h - hudAt(w, h).bottom + 8, `${kind} ${w}×${h}: bench edge ${b.near} vs shop bar ${h - hudAt(w, h).bottom}`);
}
}
});
test('with the HUD bands the bench clears the shop bar and the back row the bond strip, as large as fits', () => {
for (const [w, h] of PHONES) {
const hud = hudAt(w, h);
const top = hud.top, bottom = h - hud.bottom;
for (const kind of ['prep', 'bossPrep']) {
for (const [side, cam] of cams(kind, w, h, hud)) {
const off = presetCamera(kind, { width: w, height: h }, { side });
const b = band(cam, kind), b0 = band(off, kind);
const tag = `${kind}/${side} ${w}×${h}`;
assert.ok(b.near <= bottom + 1e-6, `${tag}: bench edge ${b.near} above the shop bar ${bottom}`);
assert.ok(b.far >= top - 1e-6, `${tag}: back row ${b.far} below the bond strip ${top}`);
// the official perspective (a 2D pan / zoom of the image): same pinhole, pitch and target
for (const k of ['tx', 'ty', 'tz', 'tilt', 'dist']) assert.equal(cam[k], off[k], `${tag}: ${k}`);
const f = cam.scale / off.scale;
assert.ok(f <= 1 + 1e-12 && f > 0.8, `${tag}: zoom ${f}`);
// zoomed out only as far as needed: the band then fills the space between the HUD bands less 1 px each end
if (f < 1 - 1e-9) assert.ok(near(b.near - b.far, bottom - top - 2, 1e-6), `${tag}: fills the free space`);
else assert.ok(near(b.near - b.far, b0.near - b0.far, 1e-6), `${tag}: pan only`);
}
}
}
});
test('desktop viewports keep the official prep camera (16:9 is flush: bench on the shop bar, back row under the strip)', () => {
for (const [w, h] of DESKTOPS) {
const hud = hudAt(w, h);
assert.deepEqual(presetCamera('prep', { width: w, height: h }, { hud }).params(), presetCamera('prep', { width: w, height: h }).params(), `prep ${w}×${h}`);
// the Final Assault bench sits up to 1.2 px lower (under the bar's top edge at 16:9): nudged up < 3 px, no zoom
for (const [side, cam] of cams('bossPrep', w, h, hud)) {
const off = presetCamera('bossPrep', { width: w, height: h }, { side });
assert.equal(cam.scale, off.scale, `bossPrep/${side} ${w}×${h}: no zoom`);
assert.ok(cam.cy <= off.cy && off.cy - cam.cy < 3, `bossPrep/${side} ${w}×${h}: nudge ${off.cy - cam.cy}`);
}
}
// other camera kinds ignore the HUD bands; the fitted (portrait) prep camera too
for (const kind of ['normal', 'unite', 'boss', 'pen']) {
assert.deepEqual(presetCamera(kind, { width: 844, height: 390 }, { hud: hudAt(844, 390) }).params(), presetCamera(kind, { width: 844, height: 390 }).params(), kind);
}
assert.deepEqual(presetCamera('prep', { width: 390, height: 844 }, { hud: hudAt(390, 844) }).params(), presetCamera('prep', { width: 390, height: 844 }).params(), 'portrait');
});
test('clearHud: unchanged when clear, pan when the band fits, zoom about the centre when it does not; garbage-safe', () => {
const vp = { width: 1000, height: 500 };
const keep = { near: 6.5, zNear: 0.16, far: 12.5, zFar: 0.42 };
const cam = presetCamera('prep', vp);
const b0 = band(cam, 'prep');
const bandH = b0.near - b0.far;
assert.equal(clearHud(cam, { top: b0.far - 10, bottom: vp.height - b0.near - 10 }, keep, vp), cam, 'clear → same object');
assert.equal(clearHud(cam, { top: b0.far + 0.4, bottom: vp.height - b0.near + 0.4 }, keep, vp), cam, '≤ 0.5 px overlap is clear');
// the bottom band reaches 30 px over the bench, 60 px free above: pan up by 31 px (1 px gap), no zoom
const pan = clearHud(cam, { top: b0.far - 60, bottom: vp.height - b0.near + 30 }, keep, vp);
assert.equal(pan.scale, cam.scale);
assert.equal(pan.cx, cam.cx);
assert.ok(near(pan.cy - cam.cy, -31, 1e-9), `pan ${pan.cy - cam.cy}`);
// 5 px over the bench and only 6 px above the back row: the gap shrinks to half the spare pixel
const tight = clearHud(cam, { top: b0.far - 6, bottom: vp.height - b0.near + 5 }, keep, vp);
assert.equal(tight.scale, cam.scale);
assert.ok(near(tight.cy - cam.cy, -5.5, 1e-9), `tight ${tight.cy - cam.cy}`);
// top band over the back row: pan down
const down = clearHud(cam, { top: b0.far + 20, bottom: 0 }, keep, vp);
assert.ok(near(down.cy - cam.cy, 21, 1e-9));
// both: zoom out about the centre column to fill [top + 1, H − bottom − 1]
const hud = { top: b0.far + 20, bottom: vp.height - b0.near + 20 };
const z = clearHud(cam, hud, keep, vp);
const f = (bandH - 42) / bandH;
assert.ok(near(z.scale / cam.scale, f, 1e-9), `zoom ${z.scale / cam.scale}`);
const bz = band(z, 'prep');
assert.ok(near(bz.far, hud.top + 1, 1e-6) && near(bz.near, vp.height - hud.bottom - 1, 1e-6));
const c0 = cam.project(0, 9, 0), c1 = z.project(0, 9, 0);
assert.ok(near(c1.x - vp.width / 2, f * (c0.x - vp.width / 2), 1e-6), 'x shrinks towards the centre');
assert.ok(near(c1.s, f * c0.s, 1e-9), 'px per tile scales by f');
// no usable space / bad input: the official camera
for (const bad of [null, 'x', { top: 300, bottom: 300 }, { top: NaN, bottom: -5 }]) {
const out = clearHud(cam, bad, keep, vp);
assert.ok(out === cam || out.params().scale === cam.scale, JSON.stringify(bad));
for (const v of Object.values(out.params())) assert.ok(Number.isFinite(v));
}
assert.equal(clearHud(cam, hud, null, vp), cam);
});
test('an adjusted camera picks, projects and lerps like any other (bench, raised and low tiles; three.js too)', () => {
const heights = (r, c) => (r === 7 ? 0.16 : r === 12 && c === 4 ? 0.42 : 0);
for (const [w, h] of [[844, 390], [800, 360]]) {
const cam = presetCamera('prep', { width: w, height: h }, { hud: hudAt(w, h) });
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
const p = cam.project(c, r, heights(r, c));
const t = pickTile(cam, p.x, p.y, heights, [0.42, 0.16, 0]);
assert.deepEqual([t.row, t.col], [r, c], `${w}×${h} pick ${r},${c}`);
}
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), w, h);
const v = new THREE.Vector3();
let worst = 0;
for (let r = 0; r < 19; r++) for (let c = 0; c < 21; c++) {
if (cam.depthOf(c, r, 0) < 0.5) continue;
const p = cam.project(c, r, 0);
v.set(c, r, 0).project(three);
worst = Math.max(worst, Math.abs((v.x + 1) / 2 * w - p.x), Math.abs((1 - v.y) / 2 * h - p.y));
}
assert.ok(worst < 1e-6, `${w}×${h} three.js: ${worst} px`);
const m = lerpCamera(presetCamera('normal', { width: w, height: h }), cam, 1).params();
for (const [k, x] of Object.entries(cam.params())) assert.ok(near(m[k], x, 1e-9), k);
}
});
});
describe('three.js PerspectiveCamera equivalence (the 3D board layer)', () => {
const kinds = ['prep', 'normal', 'unite', 'boss', 'pen', 'bossPrep'];
+138
View File
@@ -0,0 +1,138 @@
// test/render/unitedown.browser.test.js — user playtest #5 item 2 in headless Chrome: a 联防 helper's operator knocked
// out in its own combat enters the 联防 field down. The real sim runs in the page (the /sim/ modules and /data/*.json,
// like the client runner's loadBrowserSim) on a 联防 BattleSpec whose operator carries `carryState: { down: true }`,
// and feeds the render demo's field view frame by frame (local feed, the runner's b.snap / b.ev frames): from the first
// frame the operator lies on its tile in its held Die pose (no fall) under the redeploy ring counting its full timer
// while its teammate stands, then it comes back on its tile.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/unitedown.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/unitedown-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// the right-hand helper of a two-helper 联防 (colOffset +8): 德克萨斯 knocked out in its own combat,
// with 艾雅法拉 standing at half HP; the only enemy spawns long after the check (it keeps the battle running)
const SPEC = buildBattleSpec({
battleId: 'e2e.1.1.u', fieldId: 'u', kind: 'unite', seed: 77, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01',
rect: { ...GEO.UNITE_RECT }, timeLimit: 400, flags: { layerGainsEnabled: false },
spawns: [{ time: 390, enemyKey: 'enemy_1000_gopro_2', routeIndex: 0 }], routes: [{ motion: 'WALK', start: [12, 18], end: [9, 2], checkpoints: [] }],
players: [{
playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, bonds: {}, playerEffects: [],
units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_1_08_a', row: 10, col: 4, dir: 'RIGHT', items: [], carryState: { down: true } },
{ uid: 2, kind: 'chess', chessId: 'chess_char_6_20_a', row: 11, col: 3, dir: 'RIGHT', items: [], carryState: { hpPct: 0.5, sp: 0, skillActive: false } },
],
}],
});
/** Page side: the sim in the browser + a feed into the demo view. `advance(ticks)` steps and pushes one frame. */
async function installSim(spec) {
const [S, simdata, support] = await Promise.all([import('/sim/spec.js'), import('/sim/simdata.js'), import('/sim/content/support/index.js')]);
const names = ['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects'];
const raw = {};
for (const n of names) raw[n] = await fetch(`/data/${n}.json`).then((r) => r.json());
simdata.setSimData(raw);
if (typeof support.setGameData === 'function') support.setGameData(null);
const battle = S.createBattleFromSpec(spec, new simdata.DataSource(raw, null));
const v = window.__demo.view;
// what the runner's show() publishes: the field meta before the first step (nothing deployed yet)
v.enterBattle({ ...battle.fieldMeta(), fieldId: 'u', kind: 'unite', rect: spec.rect, stageId: spec.stageId });
v.setCamera('unite', { rect: spec.rect, side: 'R', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const frame = () => {
const ev = battle.drainEvents();
const gt = battle.time;
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: 'u', gt, ev });
const { t, ...rest } = battle.snapshot();
v.pushSnapshot({ ...rest, t: 'b.snap', fieldId: 'u', gt: t });
};
frame();
window.__sim = {
battle,
advance(n) { for (let i = 0; i < n; i++) battle.step(); frame(); },
unit(uid) { return battle.allyUnits.find((u) => u.uid === uid); },
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
dieClip: x.actor?.current ?? null, dieT: x.dieT ?? null, alpha: +(x.alpha ?? 0).toFixed(2), x: x.x, y: x.y, spine: !!x.spineReady };
}
return out;
},
};
}
describe('联防: an operator knocked out in its own combat enters down (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('down on its tile from the first frame, ring counting its full timer, then redeployed', async () => {
const page = await browser.newPage();
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('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installSim, SPEC);
// real speed for 2 game s (2 ticks per frame at 60 fps), then the Spine models load
const run = (frames, ticks) => page.evaluate(async (frames, ticks) => {
for (let i = 0; i < frames; i++) { window.__sim.advance(ticks); await new Promise((r) => requestAnimationFrame(() => r())); }
}, frames, ticks);
await run(60, 2);
await new Promise((r) => setTimeout(r, 1500));
await run(30, 2);
const texas = await page.evaluate(() => window.__sim.unit(1).id);
const eyja = await page.evaluate(() => window.__sim.unit(2).id);
let st = await page.evaluate(() => window.__sim.state());
await page.screenshot({ path: path.join(OUT, 'unitedown-start.png') });
assert.ok(st[texas], 'the knocked-out operator has a view on the 联防 field');
assert.equal(st[texas].alive, false);
assert.equal(st[texas].down, 0, 'down, its timer counting');
assert.ok(st[texas].ring, 'redeploy ring');
const gt = await page.evaluate(() => window.__sim.battle.time);
assert.ok(gt > 5.9 && ['64', '65', '66'].includes(st[texas].label), `its full 70 s timer (${st[texas].label} s left at ${gt.toFixed(2)} s)`);
assert.equal(st[texas].dieClip, 'Die', 'held Die pose');
assert.ok(st[texas].alpha > 0.8, `drawn (alpha ${st[texas].alpha})`);
assert.deepEqual([Math.round(st[texas].x), Math.round(st[texas].y)], [12, 10], 'on its own tile (board col 4 + 8)');
assert.equal(st[eyja].alive, true, 'the teammate stands');
// fast-forward to the redeploy (DP 10 + 1/s ≥ cost 13 long before the 70 s timer ends)
await run(40, 60);
await run(30, 2);
st = await page.evaluate(() => window.__sim.state());
await page.screenshot({ path: path.join(OUT, 'unitedown-redeployed.png') });
assert.equal(st[texas].alive, true, 'back on its tile');
assert.equal(st[texas].down, null);
assert.equal(st[texas].ring, false);
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+11 -2
View File
@@ -312,10 +312,19 @@ test('boss field with a shared pool: an emptied field (leader leaked) keeps runn
});
test('Final Assault h07_04 (pair): 昆图斯 walking into the objective does not end the field while the pool is full', { skip: !hasGeneratedData() && 'no generated data' }, () => {
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaks at ~180 s
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaked at ~180 s
// and the field used to finish 'cleared' at once, ending the Final Assault as a defeat with team LP left.
// Officially he is 自缚 (PRTS 天赋; content/bosses.js SELF_BOUND, user playtest #5) and never walks it — the engine
// rule is still checked by lifting the 自缚 buff.
const pool = new LocalBossPool(708750);
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] });
const opts = { kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] };
const bound = makeBattle({ ...opts, sharedBoss: new LocalBossPool(708750) });
bound.run(400);
assert.ok(!bound.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), '自缚: he stays');
assert.equal(bound.b.finished, false);
const h = makeBattle(opts);
assert.ok(h.runUntil(() => h.b.enemies.some((e) => e.defId === 'enemy_1521_dslily'), 5));
for (const e of h.b.enemies) if (e.defId === 'enemy_1521_dslily') h.b.removeBuff(e, 'boss:selfBound');
assert.ok(h.runUntil(() => h.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), 400), 'the leader leaks');
h.run(200);
assert.equal(h.b.finished, false);
+2 -2
View File
@@ -118,8 +118,8 @@ test('element bursts on enemies ("·我方" terms): burn 7000 元素伤害 + RES
assert.equal(e.findBuff('palsy').stacks, ELEMENT.neural.enemy.palsy);
assert.ok(!e.s.flags.stun, 'enemies are not stunned by a neural burst');
assert.equal(h.hooksOf('elementBurst').length, 2);
// element fragility multiplies 元素伤害
h.b.addBuff(e, { key: 'test:ef', mods: { elemTakenMul: 1.5 } });
// 元素脆弱 (elementalTakenMul) multiplies 元素伤害; the gauge's 元素损伤倍率 (elemTakenMul) does not
h.b.addBuff(e, { key: 'test:ef', mods: { elementalTakenMul: 1.5, elemTakenMul: 3 } });
const hp2 = e.hp;
h.b.dealDamage(null, e, { amount: 100, type: 'elemental', element: 'burn' });
approx(hp2 - e.hp, 150);
+77 -2
View File
@@ -1,13 +1,15 @@
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out.
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out. User playtest #5 item 2: an
// operator entering a battle knocked out (联防 carryState `down`, constants.js FORCED_EXIT) is down the same way.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER } from '../../server/sim/constants.js';
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER, FORCED_EXIT } from '../../server/sim/constants.js';
import { elementView } from '../../server/sim/damage.js';
import { unitStatsEntry } from '../../shared/protocol.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 12, respawnTime: 6, ...stats }, skill: null });
@@ -65,6 +67,79 @@ test('summons, devices, enemies and withdrawn / removed operators are never `dow
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
});
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
// PRTS 卫戍协议/帮助 §联防阶段: "部署完成后…上一阶段为退场状态的干员强制退场"
const h = makeBattle({
kind: 'unite', defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } },
units: [{ chessId: 't_a', row: 9, col: 5, carryState: { down: true } }, { chessId: 't_b', row: 10, col: 5, carryState: { hpPct: 0.5, sp: 0 } }],
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
});
h.step();
const a = h.unit('t_a'), b = h.unit('t_b');
assert.ok(!a.alive && h.b.isDown(a), 'down from the start');
assert.equal(a.removeReason, FORCED_EXIT);
assert.equal(a.hp, 0, 'HP 0 while down, like a knocked-out operator (the live detail card reads unit.hp)');
assert.equal(unitStatsEntry(a).hp, 0);
assert.deepEqual([a.tileR, a.tileC], [9, 5], 'on its own tile');
const d = downOf(h, a.id);
approx(d[1], 6, 0.011);
approx(d[2], 6, 0.011);
assert.equal(d[3], DOWN_STATE.COUNTING, 'its whole redeploy timer, counting');
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining shows it down');
assert.ok(b.alive && Math.abs(b.hp - 500) < 1e-6, 'the standing operator keeps its HP ratio');
// deployed (deploy effects fire), then withdrawn before battleStart; no `kill`, no 'killed' death, no death counted
const dep = h.hooksOf('deploy').find((x) => x.unit === a);
assert.ok(dep && dep.initial === true, 'initial deploy hook');
const deaths = h.hooksOf('death').filter((x) => x.unit === a);
assert.deepEqual(deaths.map((x) => x.reason), [FORCED_EXIT]);
assert.equal(h.hooksOf('kill').length, 0);
assert.equal(h.hooksOf('battleStart').length, 1);
assert.deepEqual(h.eventsOf('die').filter((e) => e[1] === a.id), [['die', a.id, FORCED_EXIT]]);
assert.equal(h.b.result().perPlayer.p1.deaths, 0, 'not a new knock-out');
h.run(6.5);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, DP short of its cost');
h.run(6);
assert.ok(a.alive && a.deployed, 'redeployed once DP ≥ cost');
assert.deepEqual([a.tileR, a.tileC], [9, 5]);
assert.equal(a.hp, a.s.maxHp, 'at full HP');
assert.equal(downOf(h, a.id), null);
// a later knock-out in the 联防 battle is an ordinary one
h.b.kill(a);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.COUNTING);
assert.equal(h.b.result().perPlayer.p1.deaths, 1);
});
test('carryState.down (联防): a redeploy-time effect that starts with the battle (机变 征召) covers the operator forced out', () => {
// 征召 "开始作战时若场上至少有一行存在3名干员,所有干员的再部署时间-50%" is switched on by a battleStart handler, after
// the forced exit: the forced-out operator's timer is re-read with it (6 → 3 s), like a later knock-out's
const run = (card) => {
const h = makeBattle({
kind: 'unite', defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c'), t_d: op('t_d') } },
players: [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null,
playerEffects: card ? [{ key: 'choice:allybuff_select_14', id: 'choice:allybuff_select_14#1', data: { effectId: 'allybuff_select_14' } }] : [],
units: [
{ uid: 1, kind: 'chess', chessId: 't_a', row: 9, col: 5, dir: 'RIGHT', items: [], carryState: { down: true } },
...['t_b', 't_c', 't_d'].map((chessId, i) => ({ uid: 2 + i, kind: 'chess', chessId, row: 10, col: 4 + i, dir: 'RIGHT', items: [], carryState: { hpPct: 1, sp: 0 } })),
],
}],
autoFinish: false, timeLimit: 120,
});
h.step();
const a = h.unit('t_a'), b = h.unit('t_b');
const forced = downOf(h, a.id);
h.b.kill(b);
const later = downOf(h, b.id);
return { forced: forced[2], forcedAt: forced[1], later: later[2] };
};
const off = run(false), on = run(true);
approx(off.forced, 6, 0.011);
approx(off.later, 6, 0.011);
approx(on.forced, 3, 0.011);
approx(on.forcedAt, 3, 0.011);
approx(on.later, 3, 0.011);
});
test('elementView: the fullest gauge (ties: the official element order), its cooldown while bursting, nothing when empty', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
+6 -5
View File
@@ -143,11 +143,12 @@ test('attract: the enemy is released, walks (own speed) to the point, waits ther
assert.equal(e().blockedBy, null, 'released');
assert.ok(e().s.flags.unblockable && e().s.flags.attract);
assert.equal(h.hooksOf('statusApplied').at(-1).entered, true);
const [x0, y0] = [e().x, e().y];
h.run(1);
// grid path: back to its tile centre (9,6), then down the column — axis-aligned legs
approx(Math.abs(e().x - x0) + Math.abs(e().y - y0), 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
h.run(4.2);
// grid path: back to the centre of the tile it stands on, then on to the point (it was blocked 0.71 tile in front of
// the guard — the block radius — i.e. on (9,7))
let walked = 0, [px, py] = [e().x, e().y];
for (let i = 0; i < 30; i++) { h.step(); walked += Math.hypot(e().x - px, e().y - py); [px, py] = [e().x, e().y]; }
approx(walked, 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
h.run(5);
approx(e().x, 6, 1e-6); approx(e().y, 11, 1e-6, 'arrived');
h.run(1);
approx(e().y, 11, 1e-6, 'waits there');
+393
View File
@@ -0,0 +1,393 @@
// test/sim/playtest5-targeting.test.js — user playtest #5 items 1 and 5 (DESIGN §19).
// #1 "有近地悬浮的敌人会被只能攻击地面单位的干员打到": a hovering enemy (近地悬浮, PRTS 术语释义 ba.float) is an air unit for
// every targeting rule (Unit.isFlying) while it keeps the ground path; it cannot be blocked or displaced (失衡免疫);
// 掠海漂移体 / 吉兆飞鳞 lose the float when stunned (their PRTS pages); 浮空 (levitate) is an air unit too.
// #5 "敌人优先锁定的干员顺序": PRTS 作战机制 索敌 — 敌方 "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被
// 攻击)→最早出现"; PRTS 卫戍协议/帮助 — "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; a stealthed ally
// is attacked only by the enemy it blocks (PRTS 作战机制 §隐匿); an enemy's own rule (只攻击地面单位) filters its
// candidates before the order; a special priority (优先攻击防御力最高的…) breaks ties by taunt, then latest deployed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
const FLOATER = 'enemy_2025_syufo'; // 掠海漂移体 (海嗣, ELEMENT faction)
const PARROT = 'enemy_10045_parrot'; // 吉兆飞鳞 (悬赏·飞行II)
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
/** Damage `src` dealt to `target` (recorded `damaged` hooks; needs captureNoisy). */
const dealt = (h, src, target) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === target).reduce((a, c) => a + c.amount, 0);
/** A pinned enemy (speed ×0) on a tile; `o.mods` merged. */
const put = (h, key, pos, o = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: o.move ? 1 : 0, ...(o.mods || {}) } });
// synthetic allies: a wall that never attacks, a melee blade, a long-range gun
const WALL = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const BLADE = chessRec({ id: 't_blade', profession: 'WARRIOR', stats: { atk: 300, maxHp: 1e7, bat: 1, blockCnt: 2 }, rangeGrid: [[0, 0], [0, 1]], skill: null });
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
const GUN = chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 300, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null });
const SHOOTER = enemyRec({ key: 'enemy_t_shooter', hp: 1e7, atk: 10, range: 9, bat: 1, speed: 0 });
const SLIME = enemyRec({ key: 'enemy_t_slime', hp: 1e7, atk: 0, speed: 0 });
const BAT = enemyRec({ key: 'enemy_t_bat', hp: 1e7, atk: 0, speed: 0, motion: 'FLY' });
const SYN = { chess: { t_wall: WALL, t_blade: BLADE, t_gun: GUN }, enemies: { enemy_t_shooter: SHOOTER, enemy_t_slime: SLIME, enemy_t_bat: BAT } };
const NOATK = () => ({ trait: { noAttack: true } });
/** Synthetic arena: generic kits + the enemies module only (other content never interferes). */
function arena(o = {}) {
return makeBattle({ content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 300, defs: SYN,
kits: { t_wall: NOATK }, captureNoisy: true, ...o });
}
// ---------------------------------------------------------------------------------------------------------------
// #1 近地悬浮
test('#1 the user\'s case: ground-only operators never hit a 近地悬浮 enemy (melee 山 / 泡泡, 迷迭香\'s ground-only shots); ranged 能天使 and the lord 银灰 do', () => {
const cases = [
['chess_char_5_17_a', 10, 5, false], // 山 (fighter, melee)
['chess_char_2_08_a', 10, 6, false], // 泡泡 (protector, melee — standing on the floater's tile)
['chess_char_6_12_a', 10, 4, false], // 迷迭香 (bombarder: "攻击对小范围的地面敌人造成…" — ranged but ground only)
['chess_char_3_01_a', 10, 4, true], // 能天使 (fastshot, ranged)
['chess_char_4_22_a', 10, 5, true], // 银灰 (lord: ranged reach, can hit air)
];
for (const [chessId, row, col, hits] of cases) {
const h = makeBattle({ units: [{ chessId, row, col }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true });
h.step();
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
assert.ok(e.isFlying && e.motion === 'WALK', 'an air unit on the ground path');
h.run(20);
const op = h.b.allyUnits.find((u) => u.kind === 'op');
const d = dealt(h, op, e);
assert.equal(d > 0, hits, `${op.name}: ${d}`);
assert.ok(!e.blockedBy, `${op.name}: never blocks it`);
checkInvariants(h.b);
}
});
test('#1 ground-only splash spares a hovering enemy next to its ground target (迷迭香 + aftershocks)', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_6_12_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, defs: SYN });
h.step();
const g = put(h, 'enemy_t_slime', [10, 6]);
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
h.run(15);
const op = h.b.allyUnits.find((u) => u.kind === 'op');
assert.ok(dealt(h, op, g) > 0, 'the ground enemy is shot');
assert.equal(dealt(h, op, e), 0, 'the floater on the same tile takes no splash');
});
test('#1 a 近地悬浮 enemy walks the ground path through blockers, cannot be displaced (失衡免疫) and ignores deep water', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 6 }] });
h.step();
const e = h.spawn(FLOATER, { routeIndex: 0, mods: { atkMul: 0 } });
const ys = new Set();
h.b.on('tick', () => { if (e.alive) ys.add(Math.round(e.y)); });
h.runUntil(() => e.x < 5.5, 60);
assert.ok(e.x < 5.5 && !e.blockedBy, 'passed the wall on (9,6)');
assert.deepEqual([...ys], [9], 'stayed on its ground lane');
const x0 = e.x;
assert.equal(h.b.displace(e, { x: 1, y: 0 }, 2, { force: 3 }), 0);
assert.equal(e.x, x0);
// 深水 (act2 m04): the floater hovers over the water — no drowning damage or slow
const w = makeBattle({ stageId: 'act2autochess_m04', autoFinish: false, timeLimit: 30,
routes: [{ motion: 'WALK', start: [11, 6], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] }],
enemies: [{ key: FLOATER, route: { motion: 'WALK', start: [11, 6], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] } }] });
w.run(3);
const f = w.enemy(FLOATER);
assert.equal(f.hp, f.s.maxHp, 'not in the water');
assert.equal(f.findBuff('terrain:deepsea'), null);
});
test('#1 掠海漂移体 stunned ⇒ 爬行模式 for good (PRTS): a ground unit — blocked, hit by melee — that only attacks its blocker', () => {
const h = arena({ units: [{ chessId: 't_blade', row: 10, col: 5 }, { chessId: 't_gun', row: 12, col: 3 }] });
h.step();
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100 } });
const blade = h.unit('t_blade'), gun = h.unit('t_gun');
h.run(3);
assert.equal(dealt(h, blade, e), 0, 'floating: the blade cannot hit it');
assert.ok(e.stats.attacks > 0, 'floating: attacks at range');
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.2, source: gun }));
assert.ok(!e.isFlying && !e.s.flags.unblockable && !e.s.flags.noDisplace, 'crawling: a ground unit');
approx(e.findBuff('stun').timeLeft, 0.5, 1e-9, 'the 0.5 s drop stun (the longer one)');
assert.ok(e.profile.melee, '仅进行阻挡攻击');
h.run(3);
assert.ok(dealt(h, blade, e) > 0, 'the blade hits it now');
// walking again it is blocked by a ground operator on its way
const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 6 }] });
h2.step();
const e2 = h2.spawn(FLOATER, { routeIndex: 0, mods: { atkMul: 0 } });
h2.b.applyStatus(e2, 'freeze', { duration: 0.5 });
h2.runUntil(() => !!e2.blockedBy || e2.x < 5.5, 60);
assert.equal(e2.blockedBy, h2.unit('t_wall'), '冻结 drops it too; then the wall blocks it');
});
test('#1 吉兆飞鳞: a stun grounds it for Stun.duration s (unblockable), then it floats again; a freeze grounds it when it ends', () => {
const h = arena();
h.step();
const e = put(h, PARROT, [10, 7]);
const down = e.mem.ab.t['Stun.duration'];
assert.ok(down > 0 && e.isFlying);
h.b.applyStatus(e, 'stun', { duration: 0.5 });
assert.ok(!e.isFlying && e.s.flags.unblockable, '晕眩模式: no float, still unblockable');
approx(e.findBuff('stun').timeLeft, down, 1e-9);
h.run(down - 0.2);
assert.ok(!e.isFlying);
h.run(0.4);
assert.ok(e.isFlying && e.s.flags.noDisplace, 'floats again (初始模式)');
h.b.applyStatus(e, 'freeze', { duration: 1 });
h.run(0.5);
assert.ok(e.isFlying, 'frozen: still floating');
h.run(0.6);
assert.ok(!e.isFlying && e.findBuff('stun'), '【失温坠落】: grounded when the freeze ends');
// "离开上述异常效果影响后进入初始模式": a sleep that outlasts the mode stun keeps it down until the sleep ends
const z = put(h, PARROT, [11, 7]);
assert.ok(h.b.applyStatus(z, 'sleep', { duration: down + 3 }));
h.run(down + 0.5);
assert.ok(!z.isFlying && z.s.flags.sleep && !z.findBuff('stun'), 'asleep after the 8 s stun: still grounded');
h.run(3);
assert.ok(z.isFlying && !z.s.flags.sleep, 'awake: floats again');
});
test('#1 浮空 (levitate) makes a ground enemy an air unit (melee cannot hit it); data flyers and levitated units refuse it, a 近地悬浮 enemy does not', () => {
const h = arena({ units: [{ chessId: 't_blade', row: 10, col: 5 }] });
h.step();
const g = put(h, 'enemy_t_slime', [10, 6]);
const blade = h.unit('t_blade');
assert.ok(canTargetEnemy(blade, g, blade.profile));
assert.ok(h.b.applyStatus(g, 'levitate', { duration: 3 }));
assert.ok(g.isFlying && !canTargetEnemy(blade, g, blade.profile) && canTargetEnemy(blade, g, { canHitFly: true }));
// PRTS 行动方式: "行动类型(数据)为飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"
assert.equal(h.b.applyStatus(g, 'levitate', { duration: 3 }), false, 'already levitated');
assert.equal(h.b.applyStatus(put(h, 'enemy_t_bat', [9, 6]), 'levitate', { duration: 3 }), false, 'a data flyer');
// 近地悬浮 is WALK in the data: it can be levitated; 浮空 is none of 掠海漂移体's drop triggers (晕眩/无法行动/沉睡/冻结/缚地)
const f = put(h, FLOATER, [11, 6]);
assert.ok(h.b.applyStatus(f, 'levitate', { duration: 3 }), 'a hovering enemy can be levitated');
assert.ok(f.isFlying && f.s.flags.stun && f.findBuff('ab:float'), 'levitated: helpless, still an air unit, still hovering');
assert.ok(!f.profile.melee, 'not in 爬行模式');
h.run(3.1);
assert.ok(!g.isFlying && canTargetEnemy(blade, g, blade.profile), 'back on the ground');
assert.ok(f.isFlying && f.findBuff('ab:float') && !f.s.flags.levitate && !f.profile.melee, 'the floater keeps hovering');
});
/**
* Chess `id` with skill `skillIndex` at (10, col) next to a pinned floating 掠海漂移体 and a ground bait on (10, 6); the
* skill is refilled every second for `secs` s. Returns the damage its side (the operator, its summons) dealt to the bait
* and — only while it still hovered — to the floater, per damage tag.
*/
function floaterDuel(id, skillIndex, { col = 5, secs = 30 } = {}) {
const BAIT = enemyRec({ key: 'enemy_t_bait', hp: 1e9, atk: 0, speed: 0 });
const h = makeBattle({ units: [{ chessId: id, row: 10, col, skillIndex }], seed: 3, autoFinish: false, timeLimit: 200, captureNoisy: true, defs: { enemies: { enemy_t_bait: BAIT } } });
h.step();
const f = put(h, FLOATER, [10, 6], { mods: { hpMul: 1e5, atkMul: 0 } });
const g = put(h, 'enemy_t_bait', [10, 6]);
const out = { floater: 0, bait: 0, floaterTags: new Map() };
h.b.on('damaged', (c) => {
if (!c.source || c.source.side !== 'ally' || !(c.amount > 0)) return;
if (c.target === g) out.bait += c.amount;
if (c.target !== f || !f.findBuff('ab:float')) return;
out.floater += c.amount;
for (const t of c.dmg?.tags || ['-']) out.floaterTags.set(t, (out.floaterTags.get(t) || 0) + c.amount);
});
const op = h.b.allyUnits.find((u) => u.kind === 'op');
for (let t = 0; t < secs; t++) {
if (op.skill && !op.skill.active) op.skill.gainSp(op.skill.spCost * (op.skill.maxCharges || 1) + 1, 'test');
h.run(1);
}
assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []);
return out;
}
test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01c offenders + the PRTS "不可对空" notes)', () => {
const cases = [
['chess_char_6_19_a', 1, 5, '锏 S2 无声的嘲笑'],
['chess_char_6_19_a', 2, 5, '锏 S3 归于宁静'],
['chess_char_6_07_a', 0, 5, '维娜·维多利亚 S1 重铸晖光'],
['chess_char_5_06_a', 2, 5, '隐德来希 S3 灵与欲的惜别'],
['chess_char_6_12_a', 2, 4, '迷迭香 S3 “如你所愿”'],
['chess_char_5_06_a', 1, 5, '隐德来希 S2 绯红壁合 (PRTS 备注: 血镰 可对空 only on a 起飞 carrier)'],
['chess_char_5_13_a', 1, 5, '归溟幽灵鲨 拥抱自我 (PRTS 备注: "伤害与减速不可对空")'],
['chess_char_3_04_a', 2, 5, '琳琅诗怀雅 S3 千金一掷 (PRTS 备注: 地面敌方单位, 弹道不可对空)'],
['chess_char_5_05_a', 0, 5, '乌尔比安 S1 必须促成的接触 (捕网, like 雪雉\'s "不对空" net)'],
];
for (const [id, idx, col, name] of cases) for (const cid of [id, id.replace(/_a$/, '_b')]) {
const r = floaterDuel(cid, idx, { col });
assert.ok(r.bait > 0, `${name} (${cid}): the ground bait is hit`);
assert.equal(r.floater, 0, `${name} (${cid}): ${JSON.stringify([...r.floaterTags])}`);
}
});
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”)', () => {
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2']]) {
const r = floaterDuel(id, idx);
assert.ok((r.floaterTags.get(tag) || 0) > 0, `${name}: ${JSON.stringify([...r.floaterTags])}`);
}
});
test('#1 阿罗玛\'s bubble levitates a hovering 掠海漂移体 (WALK in its data) without dropping it to 爬行模式', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_4_10_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 60 });
h.step();
const f = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.target === f && c.status === 'levitate'), 10);
const lev = h.hooksOf('statusApplied').find((c) => c.target === f && c.status === 'levitate');
assert.ok(lev, 'levitated by her first hit');
assert.ok(f.isFlying && f.findBuff('ab:float') && !f.profile.melee, 'still hovering, not crawling');
});
test('#1 HOVER_KEYS lists exactly the enemies that spawn hovering (the match\'s bot counts them as air units)', () => {
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const h = arena();
h.step();
const hov = [];
for (const key of Object.keys(E)) {
const e = put(h, key, [10, 6]);
if (e && e.findBuff('ab:float')) hov.push(key);
}
assert.deepEqual(hov.sort(), [...enemiesMod.HOVER_KEYS].sort());
});
// ---------------------------------------------------------------------------------------------------------------
// #5 enemy target order
/** The ally the latest attack of `enemy` targeted. */
function lastTarget(h, enemy) {
const ev = h.eventsOf('atk').filter((x) => x[1] === enemy.id).pop();
return ev ? h.b.unitById(ev[2]) : null;
}
test('#5 unblocked ranged enemies: highest taunt, then the latest deployed — the bottom row, rightmost (从上到下 > 从左到右)', () => {
const units = [[12, 5], [11, 3], [9, 4], [9, 7], [10, 8]].map(([row, col], i) => ({ chessId: 't_wall', row, col, uid: i + 1 }));
const h = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
h.step();
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => [c.unit.tileR, c.unit.tileC]);
assert.deepEqual(order, [[12, 5], [11, 3], [10, 8], [9, 4], [9, 7]]);
h.run(1.2);
const e = h.enemy('enemy_t_shooter');
const t = lastTarget(h, e);
assert.deepEqual([t.tileR, t.tileC], [9, 7], 'the last deployed');
// a taunt level beats deployment order
const h2 = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
h2.step();
h2.b.addBuff(h2.unit(1), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
h2.run(1.2);
assert.equal(lastTarget(h2, h2.enemy('enemy_t_shooter')), h2.unit(1));
});
test('#5 the initial deployment puts every operator before the summons (PRTS 卫戍协议/帮助) — a summon piece draws the fire', () => {
const tok = { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 12, col: 4, uid: 3, def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } };
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3, uid: 1 }, { chessId: 't_wall', row: 9, col: 8, uid: 2 }, tok], enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
h.step();
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.uid);
assert.deepEqual(order, [1, 2, 3], 'operators top→bottom, then the summon');
const t = h.unit(3);
assert.ok(t.aggroSeq > h.unit(1).aggroSeq && t.aggroSeq > h.unit(2).aggroSeq);
h.run(1.2);
assert.equal(lastTarget(h, h.enemy('enemy_t_shooter')), t);
// a summon an operator brings along at the start ranks after every operator too
const h2 = arena({ units: [{ chessId: 't_wall', row: 12, col: 3, uid: 1 }, { chessId: 't_wall', row: 9, col: 8, uid: 2 }], enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }],
setup: (b) => b.on('deploy', (c) => { if (c.initial && c.unit.uid === 1) b.spawnToken(c.unit, 'token_10028_vigil_wolf', 12, 4, { def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } }); }) });
h2.step();
const s = h2.b.allyUnits.find((u) => u.kind === 'token');
assert.ok(s.deploySeq < h2.unit(2).deploySeq, 'it came in during the first operator\'s deployment');
assert.ok(s.aggroSeq > h2.unit(2).aggroSeq, '…but ranks after every operator');
h2.run(1.2);
assert.equal(lastTarget(h2, h2.enemy('enemy_t_shooter')), s);
// a redeployed operator is the latest deployed again
h2.b.retreat(h2.unit(1), { reason: 'retreat' });
h2.b.redeploy(h2.unit(1), { free: true });
assert.ok(h2.unit(1).aggroSeq > s.aggroSeq);
});
test('#5 a blocked ranged enemy attacks its blocker first', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6, uid: 1 }, { chessId: 't_wall', row: 9, col: 7, uid: 2 }], enemies: [{ key: 'enemy_t_shooter', pos: [10, 6] }] });
h.step();
h.run(1.2);
const e = h.enemy('enemy_t_shooter');
assert.equal(e.blockedBy, h.unit(1));
assert.equal(lastTarget(h, e), h.unit(1), 'not the later-deployed one beside it');
});
test('#5 隐匿 / 迷彩 allies: only the enemy they block attacks them — blocking does not expose them to other ranged enemies (PRTS 作战机制 §隐匿, 索敌的概念)', () => {
const units = [{ chessId: 't_wall', row: 12, col: 5, uid: 1 }, { chessId: 't_wall', row: 9, col: 7, uid: 2 }];
const mk = (stealth) => {
const h = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 5] }, { key: 'enemy_t_shooter', pos: [9, 7] }] });
h.step();
if (stealth) h.b.applyStatus(h.unit(2), 'stealth', { duration: 99 });
h.run(1.2);
const held = h.b.enemies.find((e) => e.blockedBy === h.unit(2));
const free = h.b.enemies.find((e) => e !== held);
assert.ok(held && free && !free.blockedBy);
return { h, held, free };
};
const a = mk(true);
assert.equal(lastTarget(a.h, a.held), a.h.unit(2), 'the enemy it blocks attacks it ("强行无视对方可选性")');
assert.equal(lastTarget(a.h, a.free), a.h.unit(1), 'an unblocked ranged enemy skips it although it blocks ("我方干员并不会因为阻挡而解除隐匿")');
const b = mk(false);
assert.equal(lastTarget(b.h, b.free), b.h.unit(2), 'control: without stealth the latest deployed is shot');
// 排气格栅 (烟雾) uses the same stealth flag: test/content/tokens_devices.test.js
});
test('#5 an enemy\'s own rule filters its candidates first: 萨卡兹枯朽战车 (只攻击地面单位) shoots the ground operator, never idles', () => {
// (10,2) is high ground: that operator deploys last (row 10 after row 11) and would be the preferred target
const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 2, uid: 2 }] });
h.step();
assert.equal(h.unit(2).ground, false);
const e = put(h, 'enemy_1272_nhtank', [10, 4], { mods: { atkMul: 0.01 } });
h.runUntil(() => e.stats.attacks >= 2, 20);
assert.ok(e.stats.attacks >= 2, 'it attacks');
const targets = h.eventsOf('atk').filter((x) => x[1] === e.id).map((x) => x[2]);
assert.ok(targets.every((id) => id === h.unit(1).id), 'only the ground operator');
// PRTS 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位": a flying ally on a low tile (the 炎佑 dragon) is no target
const h2 = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 5, uid: 2 }] });
h2.step();
h2.unit(2).motion = 'FLY';
assert.ok(h2.unit(2).ground && h2.unit(2).isFlying);
const e2 = put(h2, 'enemy_1272_nhtank', [10, 4], { mods: { atkMul: 0.01 } });
h2.runUntil(() => e2.stats.attacks >= 2, 20);
assert.ok(e2.stats.attacks >= 2);
assert.ok(h2.eventsOf('atk').filter((x) => x[1] === e2.id).every((x) => x[2] === h2.unit(1).id), 'never the flyer');
});
test('#5 “萨科塔之眼” (不会攻击飞行单位) filters its candidates: it shoots the ground ally, never the later-deployed flyer', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 5, uid: 2 }] });
h.step();
h.unit(2).motion = 'FLY';
const e = put(h, 'enemy_10084_hlegle', [10.5, 5.5], { mods: { atkMul: 0.01 } });
h.runUntil(() => e.stats.attacks >= 2, 20);
assert.ok(e.stats.attacks >= 2, 'it attacks');
assert.ok(h.eventsOf('atk').filter((x) => x[1] === e.id).every((x) => x[2] === h.unit(1).id), 'only the ground ally');
});
test('#5 a shared field (boss / 联防) deploys the players side by side — the i-th operators together, then the summons [ASSUMED]', () => {
const W = chessRec({ id: 't_w', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const tok = { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 12, col: 6, uid: 5, def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } };
const h = makeBattle({ kind: 'boss', content: 'none', autoFinish: false, defs: { chess: { t_w: W } },
players: [
{ playerId: 'L1', side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_w', row: 12, col: 4 }, { uid: 2, chessId: 't_w', row: 11, col: 4 }, { uid: 3, chessId: 't_w', row: 10, col: 4 }, tok] },
{ playerId: 'R1', side: 'R', colOffset: 8, units: [{ uid: 4, chessId: 't_w', row: 12, col: 4 }] },
] });
h.step();
assert.deepEqual(h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.uid), [1, 4, 2, 3, 5],
'not every left operator before the right one: at equal taunt the leader does not always prefer one player');
const seq = (uid) => h.b.allyUnits.find((u) => u.uid === uid).aggroSeq;
assert.ok(seq(5) > Math.max(seq(1), seq(2), seq(3), seq(4)), 'the summon ranks after every operator');
});
test('#5 a special priority breaks ties by taunt, then the latest deployed (假想敌:铳 "优先攻击防御力最高的单位"; PRTS 索敌: 特殊优先级 → 仇恨值)', () => {
const pool = { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
const duel = (taunt) => {
const h = makeBattle({ kind: 'boss', sharedBoss: pool, content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: SYN, kits: { t_wall: NOATK }, units: [{ chessId: 't_wall', row: 10, col: 9, uid: 1 }, { chessId: 't_wall', row: 10, col: 11, uid: 2 }] });
h.step();
if (taunt) h.b.addBuff(h.unit(1), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
const e = h.spawn('enemy_9017_achunt', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
h.runUntil(() => e.stats.attacks >= 1, 60);
assert.ok(e.stats.attacks >= 1);
assert.equal(h.unit(1).s.def, h.unit(2).s.def, 'equal DEF');
return { h, t: lastTarget(h, e) };
};
const a = duel(false);
assert.equal(a.t, a.h.unit(2), 'equal DEF: the latest deployed');
const b = duel(true);
assert.equal(b.t, b.h.unit(1), 'equal DEF: the higher taunt level first');
});
+271
View File
@@ -0,0 +1,271 @@
// User playtest #5 item 4 — "如果敌人被卡在两个干员之间的话,靠后的干员死亡敌人会直接走掉,而不是换成靠前面的干员接替阻挡".
//
// Official rule (PRTS 游戏数据基础 §阻挡半径, 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked ground
// enemy whose position lies within the ground block radius 0.7071 (squared 0.49999037) of a ground operator's centre is
// blocked by it while the operator has free block capacity — checked continuously, so an enemy that overlaps an
// operator when its blocker dies (or when the operator's capacity frees up) is taken over at once. Flying enemies:
// air block radius 0.8944 (起飞 / blockFly units); devices (阻隔工事 / 障碍物): 0.4472.
//
// Flat stage: lower-gate enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { BLOCK_RADIUS } from '../../server/sim/constants.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const FRONT = 'test_front_a', BACK = 'test_back_a';
const op = (id, blockCnt = 1) => chessRec({ id, stats: { atk: 0, blockCnt, maxHp: 1e6, respawnTime: 999 }, skill: null });
const foe = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 1, ...o });
function lane(units, enemies, defs = {}) {
return makeBattle({
defs: {
chess: { [FRONT]: op(FRONT, defs.frontBlock ?? 1), [BACK]: op(BACK, defs.backBlock ?? 1), ...(defs.chess || {}) },
enemies: { enemy_x: foe('enemy_x'), enemy_e: foe('enemy_e'), enemy_heavy: foe('enemy_heavy', { blockCnt: 2 }), ...(defs.enemies || {}) },
},
units, enemies, timeLimit: 200, content: 'none',
});
}
const kill = (h, u) => h.b.loseHp(u, 1e12);
test('the official block radii are the PRTS numbers', () => {
assert.ok(Math.abs(BLOCK_RADIUS.ground - 0.7071) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.fly - 0.8944) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.device - 0.4472) < 1e-3);
});
test('#4: the enemy between two operators is taken over by the front one when its blocker (the rear one) dies', () => {
// front (9,6) blocks X; E walks through the full front operator and is blocked by the rear one (9,5) — it now
// overlaps both. X dies (front has room again), then the rear blocker dies: E must stay, blocked by the front one.
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30), 'X blocked by the front, E by the rear operator');
const ex = e().x;
assert.ok(Math.hypot(ex - 6, e().y - 9) < BLOCK_RADIUS.ground, `E overlaps the front operator too (x=${ex.toFixed(3)})`);
kill(h, x());
h.step(2);
assert.equal(e().blockedBy, back, 'E stays with its blocker while the rear operator lives');
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, front, 'the front operator takes E over');
h.run(5);
assert.equal(e().blockedBy, front);
assert.ok(Math.abs(e().x - ex) < 1e-6, 'E did not walk on');
checkInvariants(h.b);
});
test('#4: a withdrawn or stunned blocker hands its enemy to the operator it still touches', () => {
for (const how of ['retreat', 'stun']) {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, x());
if (how === 'retreat') h.b.retreat(back); else h.b.applyStatus(back, 'stun', { duration: 5 });
h.step(2);
assert.equal(e().blockedBy, front, how);
checkInvariants(h.b);
}
});
test('#4: the rear blocker dies while the front operator is still full — the enemy walks on (no room)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, null, 'no room at the front → E walks on');
const x0 = e().x;
h.run(1);
assert.ok(e().x < x0 - 0.2, 'E is walking again');
checkInvariants(h.b);
});
test('#4: an operator whose capacity frees up blocks an enemy that still overlaps it (past its tile, inside 0.7071)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
// E walks through the full operator; free the capacity once E has left the operator's tile (x < 5.5) but is still
// within the block radius
assert.ok(h.runUntil(() => e() && e().x < 5.42, 30));
assert.equal(e().blockedBy, null, 'passing through while full');
kill(h, x());
h.step(1);
assert.equal(e().blockedBy, front, 'taken as soon as the operator has room');
checkInvariants(h.b);
});
test('#4: pass-through when full — an enemy that never finds room walks past the operator', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => e() && e().x < 4.5, 40), 'E walked past the full operator');
assert.equal(e().blockedBy, null);
assert.equal(front.blocking.length, 1);
checkInvariants(h.b);
});
test('#4: contact happens at the block radius (the enemy stops ~0.71 tile from the operator, outside its tile)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
const d = Math.hypot(x().x - 6, x().y - 9);
assert.ok(d <= BLOCK_RADIUS.ground + 1e-9 && d > BLOCK_RADIUS.ground - 0.05, `blocked at contact (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: an operator beside the lane (one row off) never touches the enemies walking it', () => {
const h = lane([{ chessId: FRONT, row: 10, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x() && x().x < 4, 40));
assert.equal(h.hooksOf('blocked').length, 0);
});
test('#4: block weight — a 2-weight enemy needs 2 free block', () => {
const h1 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }]);
const heavy1 = () => h1.b.enemies.find((e) => e.defId === 'enemy_heavy');
assert.ok(h1.runUntil(() => heavy1() && heavy1().x < 4.5, 40), 'block 1 cannot hold a 2-weight enemy');
assert.equal(heavy1().blockedBy, null);
const h2 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }], { frontBlock: 2 });
assert.ok(h2.runUntil(() => h2.b.enemies[0]?.blockedBy === h2.unit(FRONT), 30), 'block 2 holds it');
});
test('#4: of two free operators in contact the nearer one blocks', () => {
// an enemy standing still between (9,6) and (9,5), nearer to (9,5)
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }], [],
{ enemies: { enemy_still: foe('enemy_still', { speed: 0 }) } });
h.step(1);
const e = h.spawn('enemy_still', { pos: [9, 5.4] });
h.step(1);
assert.equal(e.blockedBy, h.unit(BACK));
checkInvariants(h.b);
});
test('#4: a blocker attacks the enemy it blocks even when it stands outside its range (可以选择且优先选择阻挡单位)', () => {
// facing LEFT (away from the gate): its range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71 (tile (9,7))
const h = makeBattle({
defs: { chess: { t_back: chessRec({ id: 't_back', stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: null }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: 't_back', row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit('t_back');
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
});
test('#4: a ranged operator on a melee tile blocks, but never attacks or casts for the enemy held behind it (阻挡(近战限定))', () => {
// PRTS 索敌的概念: "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在
// 该干员的身后,该干员可能因为敌人未处于自身攻击范围而无法攻击到这个敌人". Facing LEFT, its range (9,6),(9,5) never holds
// the enemy blocked at x ≈ 6.71; a melee operator in the same spot hits it and casts its DEFAULT skill (帮助: "敌人被近战
// 干员自身阻挡" satisfies the target condition).
const run = (id, profession) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
content: 'generic', kits: { [id]: () => ({ skill: { kind: 'duration', duration: 5 } }) }, hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), `${profession} blocks`);
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
checkInvariants(h.b);
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
};
const ranged = run('t_rg', 'SNIPER');
assert.equal(ranged.hits, 0, 'the ranged blocker never hits the enemy behind it');
assert.equal(ranged.casts, 0, 'nor casts its skill for it');
assert.ok(ranged.still, 'it still holds the enemy');
const melee = run('t_ml', 'WARRIOR');
assert.ok(melee.hits > 0, 'a melee blocker hits it');
assert.equal(melee.casts, 1, 'and casts its skill');
});
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
// data position MELEE, attackKind ranged (领主 / 哨戒铁卫 / 要塞 / 钩索师 …): a melee unit for the blocked-first selector
const id = 'chess_char_4_22_a';
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.equal(u.def.position, 'MELEE');
assert.equal(u.profile.attack, 'ranged', 'a ranged attack profile');
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), 'blocks');
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range (behind it)');
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
});
test('#4: blocked-first is a melee priority — a ranged blocker shoots the enemy nearest the goal', () => {
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
// goal. The ranged operator picks by its own order (least remaining distance first), a melee one (able to hit air)
// keeps hitting the enemy it blocks.
const run = (id, o) => {
const h = makeBattle({
defs: {
chess: { [id]: chessRec({ id, rangeGrid: [[0, -1], [0, 0], [0, 1]], stats: { atk: 1, bat: 0.2, blockCnt: 1, maxHp: 1e6 }, skill: null, ...o }) },
enemies: { enemy_x: foe('enemy_x'), enemy_fl: foe('enemy_fl', { motion: 'FLY' }) },
},
units: [{ chessId: id, row: 9, col: 6 }], enemies: [{ key: 'enemy_x', time: 0 }, { key: 'enemy_fl', time: 5, route: 2 }], timeLimit: 60,
content: 'none', hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const walker = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const flyer = () => h.b.enemies.find((e) => e.defId === 'enemy_fl');
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
assert.equal(walker().blockedBy, u);
return h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length;
};
assert.ok(run('t_rg2', { profession: 'SNIPER' }) > 0, 'the ranged blocker shot the flyer while it was nearer the goal');
assert.equal(run('t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }), 0, 'the melee blocker kept to its blocked enemy');
});
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
const h = makeBattle({
defs: { chess: { t_sky: chessRec({ id: 't_sky', subProfessionId: 'skywalker', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null }) },
enemies: { enemy_fl: foe('enemy_fl', { motion: 'FLY' }) } },
units: [{ chessId: 't_sky', row: 9, col: 6 }], enemies: [{ key: 'enemy_fl', route: 2 }], timeLimit: 60,
});
const sky = h.unit('t_sky');
const f = () => h.b.enemies[0];
assert.ok(h.runUntil(() => f()?.blockedBy === sky, 30), 'the flyer is blocked');
const d = Math.hypot(f().x - 6, f().y - 9);
assert.ok(d <= BLOCK_RADIUS.fly + 1e-9 && d > BLOCK_RADIUS.ground, `at the air radius (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outside her tile (PRTS: 自身这格内…及自身阻挡的敌人)', () => {
const ds = getDefaultSource();
const id = 'chess_char_3_12_a';
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60,
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.notEqual(Math.round(x().x), 6, 'the blocked enemy is on the tile in front of her');
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
assert.ok(h.runUntil(() => u.skill.active, 5));
h.step();
assert.ok(x().s.flags.sleep, 'asleep');
});
+230
View File
@@ -0,0 +1,230 @@
// User playtest #5 item 10 — "大体型的关底boss实际受击判定范围很小,是不是就一格,跟官方原版不一样,原版是很大的一片区域".
//
// Official (PRTS enemy pages, 天赋 "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0"; 假想敌:管 隐秘核心 also
// "向右偏移1.0"; 卫戍协议:盟约 下半/PRTS盟约记录: the season's 阿利斯泰尔 / “萨米的意志” have the same 4.95 × 2.95, up 1) and
// PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定": a huge boss is hit anywhere on its rectangle —
// every tile it occupies counts for grid ranges, radius effects measure to the rectangle. Regular enemies are
// unchanged.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { bodyKeys, bodyDist, bodyInKeys, bodyOnTile, bodyTileReach, hitRect, normHitArea } from '../../server/sim/body.js';
import { COLS } from '../../server/sim/constants.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const tiles = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const block = (r0, r1, c0, c1) => { const o = []; for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) o.push([r, c]); return o; };
test('data: the huge bosses carry the PRTS hit areas, everything else is a point', () => {
const want = {
enemy_9013_acstmk: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9013_acstmk_2: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9021_acduml: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9021_acduml_2: { w: 4.95, h: 2.95, dx: 1, dy: 1 },
enemy_1521_dslily: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9032_aclionk: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
};
for (const [k, v] of Object.entries(want)) assert.deepEqual(E[k].hitArea, v, k);
const others = Object.keys(E).filter((k) => E[k].hitArea && !want[k]);
assert.deepEqual(others, [], 'no other enemy is huge (假想敌:铳 / 卢西恩 / the parts are regular)');
for (const k of ['enemy_9017_achunt', 'enemy_9017_achunt_2', 'enemy_2016_csphtm', 'enemy_9014_acstma', 'enemy_9022_acdumm']) assert.equal(E[k].hitArea, undefined, k);
assert.equal(normHitArea({ w: 0, h: 1 }), null);
assert.equal(normHitArea(null), null);
});
test('geometry: 4.95 × 2.95 up 1 at the boss tile (3,10) occupies rows 3–5 × cols 8–12; 管 (隐秘核心) at (3,9) the same block', () => {
const boss = { x: 10, y: 3, hitArea: normHitArea(E.enemy_9013_acstmk.hitArea) };
assert.deepEqual(tiles(bodyKeys(boss)), block(3, 5, 8, 12));
const pipe2 = { x: 9, y: 3, hitArea: normHitArea(E.enemy_9021_acduml_2.hitArea) };
assert.deepEqual(tiles(bodyKeys(pipe2)), block(3, 5, 8, 12));
assert.ok(bodyOnTile(boss, 5, 12) && bodyOnTile(boss, 3, 8) && !bodyOnTile(boss, 2, 10) && !bodyOnTile(boss, 4, 13));
const R = hitRect(boss);
assert.deepEqual([R.x0, R.x1, R.y0, R.y1].map((v) => +v.toFixed(3)), [7.525, 12.475, 2.525, 5.475]);
assert.equal(bodyDist(boss, 10, 4), 0, 'inside');
assert.ok(Math.abs(bodyDist(boss, 13.475, 4) - 1) < 1e-9, '1 tile right of the edge');
assert.equal(bodyTileReach(boss, 4, 13), 1);
assert.equal(bodyTileReach(boss, 4, 12), 0);
// a regular enemy is its position's tile / a point
const e = { x: 6.4, y: 9.6 };
assert.deepEqual(tiles(bodyKeys(e)), [[10, 6]]);
assert.ok(bodyInKeys(e, new Set([10 * COLS + 6])) && !bodyInKeys(e, [10 * COLS + 7]));
assert.ok(Math.abs(bodyDist(e, 6.4, 10.6) - 1) < 1e-9);
});
// ---------------------------------------------------------------------------------------------------------------
// battles
const HUGE = { ...enemyRec({ key: 'enemy_huge', hp: 1e8, speed: 0, rank: 'BOSS' }), hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } };
const SMALL = enemyRec({ key: 'enemy_small', hp: 1e8, speed: 0 });
const blade = (id, rangeGrid = [[0, 0], [0, 1]]) => chessRec({ id, stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid, skill: null });
function bossField(units, key, pos = [3, 10], extra = {}) {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_edge: blade('t_edge'), t_far: blade('t_far'), t_aoe: extra.aoe ?? blade('t_aoe') }, enemies: { enemy_huge: HUGE, enemy_small: SMALL } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: units.map((u, i) => ({ uid: i + 1, kind: 'chess', ...u })), bonds: {} }],
});
h.step();
const e = h.spawn(key, { pos, routeIndex: 0 });
return { h, e };
}
const hitsOn = (h, e, u) => h.hooksOf('damaged').filter((c) => c.target === e && c.source === u).length;
test('#10: an operator whose range touches only the edge of the boss area hits it (and not a regular enemy there)', () => {
// (4,7) facing RIGHT covers (4,7),(4,8) — (4,8) is the left column of the boss body; the boss's own tile (3,10) is
// far
for (const [key, expectHit] of [['enemy_huge', true], ['enemy_small', false]]) {
const { h, e } = bossField([{ chessId: 't_edge', row: 4, col: 7, dir: 'RIGHT' }], key);
const u = h.unit('t_edge');
h.run(5);
assert.equal(hitsOn(h, e, u) > 0, expectHit, `${key}: ${hitsOn(h, e, u)} hits`);
checkInvariants(h.b);
}
});
test('#10: every tile of the 5 × 3 body counts; one tile outside it does not', () => {
// a 1-tile range operator (its own tile only) on the boss's corner tiles (5,12) and (3,8) hits; on (2,9) (below it) /
// (4,13) (right of it) it does not
const one = [[0, 0]];
for (const [r, c, want] of [[5, 12, true], [3, 8, true], [5, 8, true], [4, 13, false], [2, 9, false]]) {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_one: blade('t_one', one) }, enemies: { enemy_huge: HUGE } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_one', row: r, col: c }], bonds: {} }],
});
h.step();
const e = h.spawn('enemy_huge', { pos: [3, 10], routeIndex: 0 });
h.run(4);
assert.equal(hitsOn(h, e, h.unit('t_one')) > 0, want, `(${r},${c})`);
}
});
test('#10: radius effects reach the body — an AoE 1 tile beyond the rectangle edge hits it, a regular enemy there is not hit', () => {
for (const [key, want] of [['enemy_huge', 1], ['enemy_small', 0]]) {
const { h, e } = bossField([], key);
const got = h.b.enemiesInRadius(13.475, 4, 1.0).filter((x) => x === e).length;
assert.equal(got, want, key);
assert.equal(h.b.enemiesInRadius(13.6, 4, 1.0).filter((x) => x === e).length, 0, `${key}: beyond reach`);
// splash around a target is a 中点判定 (PRTS 作战机制): by position, the body does not count
assert.equal(h.b.enemiesInRadius(13.475, 4, 1.0, true).filter((x) => x === e).length, 0, `${key}: centre-point radius`);
assert.equal(h.b.enemiesInRadius(11, 3, 1.0, true).filter((x) => x === e).length, 1, `${key}: its position within r`);
}
});
test('#10 (follow-up): an operator\'s splash on an escort next to the huge boss does not reach the boss (中点判定)', () => {
// escort at (2,9): 0.525 below the boss rectangle (rows 2.525–5.475), 1.41 from the boss's position (3,10). A splash
// caster targeting only the escort (range: its tile) splashes 1.1 around it — by PRTS 作战机制 "中点判定…酒神1天赋的1.3
// 溅射半径" the boss (centre 1.41 away) is not hit; a regular enemy 1.0 away from the escort is
const aoe = chessRec({ id: 't_aoe', profession: 'CASTER', subProfessionId: 'splashcaster', dmgType: 'arts', stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid: [[0, 3]], skill: null });
const { h, e } = bossField([{ chessId: 't_aoe', row: 2, col: 6, dir: 'RIGHT' }], 'enemy_huge', [3, 10], { aoe });
const esc = h.spawn('enemy_small', { pos: [2, 9], routeIndex: 0 });
const near = h.spawn('enemy_small', { pos: [2, 10], routeIndex: 0 });
const u = h.unit('t_aoe');
assert.ok(h.runUntil(() => hitsOn(h, esc, u) > 0, 10), 'the caster hits the escort');
h.run(3);
assert.ok(hitsOn(h, near, u) > 0, 'a regular enemy 1.0 from the escort takes the splash');
assert.equal(hitsOn(h, e, u), 0, 'the huge boss (position 1.41 away, body 0.53 away) takes none');
checkInvariants(h.b);
});
test('#10: the enemy index lists a huge enemy once per query and unitsInGrid sees its body', () => {
const { h, e } = bossField([{ chessId: 't_far', row: 4, col: 3, dir: 'RIGHT' }], 'enemy_huge');
h.step();
const all = [];
for (let r = 0; r <= 5; r++) for (let c = 0; c <= 20; c++) all.push(r * COLS + c);
const u = h.unit('t_far');
assert.deepEqual(h.b.enemiesInKeys(all, u, { canHitFly: true }).map((x) => x.id), [e.id]);
assert.deepEqual(h.b.unitsInGrid(u, [[0, 5]]).map((x) => x.id), [e.id], '(4,8) is on the body');
assert.deepEqual(h.b.unitsInGrid(u, [[0, 4]]).map((x) => x.id), [], '(4,7) is not');
});
test('#10: the real 假想敌:胄 on the real boss stage — an operator on the 1×3 fence left of the pipe block hits it', () => {
// act2autochess_m01 boss field: floor cols 9–11 rows 3–5 framed by fence tiles (b, deployable) at cols 8 and 12;
// the boss stands at (3,10). A melee operator on the fence (4,8) facing RIGHT covers (4,8),(4,9) — both on the boss
// body.
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_edge: blade('t_edge') } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_edge', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
});
h.step();
const boss = h.spawn('enemy_9013_acstmk', { pos: [3, 10], routeIndex: 0, tag: 'boss', mods: { speedMul: 0 } });
assert.ok(boss.hitArea, 'the boss unit carries its hit area');
const u = h.unit('t_edge');
assert.equal(u.tileR, 4);
h.run(6);
assert.ok(hitsOn(h, boss, u) > 0, 'hits the boss');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 自缚 + 无法被阻挡 — every 巨型单位 page lists "{{异常效果|自缚}} … 不可阻挡 / 无法被阻挡" (PRTS 假想敌:胄 / 假想敌:管 /
// 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志”; 异常效果 自缚 = 无法移动, not recognised as 束缚). The hit area moves
// with the boss, so a boss that walked would drag its 5 × 3 body off the pipe block (reviewer: the co-op 昆图斯 walked its
// route (3,10) → (2,2) and leaked at ≈ 280 game s).
const HUGE_TEMPLATES = [
['act1autochess_h07_01_s', 'enemy_9013_acstmk'], ['act1autochess_h07_03_s', 'enemy_9021_acduml'],
['act1autochess_h07_04_s', 'enemy_1521_dslily'], ['act1autochess_h07_06_s', 'enemy_9032_aclionk'],
['act1autochess_h07_07_s', 'enemy_9033_acdeer'], ['act1autochess_h08_01_s', 'enemy_9013_acstmk_2'],
['act1autochess_h08_03_s', 'enemy_9021_acduml_2'],
];
const realBoss = (waveTemplate, units = [], timeLimit = 60, two = false) => makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', waveTemplate, content: 'full', autoFinish: false, timeLimit, hooks: ['enemyLeak'],
players: [
{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units, bonds: {} },
...(two ? [{ playerId: 'p2', seat: 1, side: 'R', coords: 'field', units: [], bonds: {} }] : []),
],
});
test('#10: every huge boss is 自缚 and unblockable (PRTS 天赋) — in its real template, from its spawn', async () => {
const { SELF_BOUND } = await import('../../server/sim/content/bosses.js');
assert.deepEqual([...SELF_BOUND].sort(), Object.keys(E).filter((k) => E[k].hitArea).sort(), 'the 巨型单位 are exactly the 自缚 leaders');
for (const [tpl, key] of HUGE_TEMPLATES) {
const h = realBoss(tpl);
h.run(2); // 管 spawns at 1 s
const e = h.b.enemies.find((x) => x.defId === key);
assert.ok(e, `${tpl}: ${key} spawned`);
assert.ok(e.s.flags.noMove && e.s.flags.unblockable, `${key}: 自缚 + 无法被阻挡`);
assert.ok(!e.s.flags.bind, `${key}: 自缚 is not recognised as 束缚`);
}
});
test('#10: the co-op 盐风主教昆图斯 stays on its pipe block (its route would walk it to (2,2)) and never leaks', () => {
const h = realBoss('act1autochess_h07_04', [], 900, true);
h.run(1);
const bosses = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
const at = bosses.map((e) => [e.x, e.y]);
h.run(400);
const q = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
assert.ok(q.length >= 1 && q.every((e) => e.alive), 'still on the field');
for (const [i, e] of bosses.entries()) assert.deepEqual([e.x, e.y], at[i], `copy ${i} did not move`);
assert.equal(h.hooksOf('enemyLeak').filter((c) => c.enemy && c.enemy.defId === 'enemy_1521_dslily').length, 0, 'no leak');
checkInvariants(h.b);
});
test('#10: an operator standing on the boss (a free-capacity blocker in contact) does not block it', () => {
const op = chessRec({ id: 't_wall', stats: { atk: 10, blockCnt: 3, maxHp: 1e9, def: 5000 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', content: 'full', autoFinish: false, timeLimit: 60, defs: { chess: { t_wall: op } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_wall', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
});
h.step();
const u = h.unit('t_wall');
const boss = h.spawn('enemy_1521_dslily', { pos: [u.tileR, u.tileC], routeIndex: 0, tag: 'boss' });
h.run(3);
assert.equal(boss.blockedBy, null);
assert.equal(u.blocking.length, 0);
assert.deepEqual([boss.y, boss.x], [u.tileR, u.tileC], 'and it does not walk away either');
});
test('docs: SIM.md / DATA.md describe the hit areas and the block radius (user playtest #5)', () => {
const SIM = fs.readFileSync(new URL('../../docs/SIM.md', import.meta.url), 'utf8');
const DATA_MD = fs.readFileSync(new URL('../../docs/DATA.md', import.meta.url), 'utf8');
assert.match(SIM, /body\.js/);
assert.match(SIM, /受击判定区域为长4\.95、宽2\.95的长方形,向上偏移1\.0/);
assert.match(SIM, /BLOCK_RADIUS`: ground 0\.7071/);
assert.match(SIM, /Battle\.blockedTargets/);
assert.match(SIM, /自缚 \+ 无法被阻挡[\s\S]{0,80}SELF_BOUND/);
assert.match(DATA_MD, /\| `hitArea` \|/);
});
+423
View File
@@ -0,0 +1,423 @@
// test/sim/playtest5_elements.test.js — user playtest #5 report 3 (enemy side): "海嗣敌人元素损伤效率有点低 — check every
// enemy's element damage and element gauge against the official game". Encodes the official numbers (enemy_database /
// PRTS 元素 / 游戏数据基础 / the enemies' PRTS pages / gamedata_const termDescriptionDict) for the gauge pipeline
// (server/sim/damage.js) and the enemies' element abilities (content/enemies.js):
// · 海嗣 enemies take element damage at the full rate (损伤抵抗 0, 1000 / leader 2000) and deal ATK × ratio per hit;
// · 元素损伤倍率 (elemTakenMul) scales the gauge, 元素脆弱 (elementalTakenMul) the 元素伤害 — never each other;
// · 损伤抵抗 (epResistance) reduces the gauge, 元素抗性 (epDamageResistance) the 元素伤害 (both with the 5 % floor);
// · bursts deal 无来源 damage (no damage-dealt multiplier / penetration of the unit that filled the gauge, no content hook
// sees a source — 深巡's 海怪 ×1.5, 精准狙击镜 — while the credit, the kill and damage sharing keep working);
// · 麻痹免疫 enemies get no 麻痹 from a 神经 burst;
// · 淤困 spreads 1000, 灼藤's first attack covers 3×3, 节日爵士乐手 channels on one locked target;
// · target-side 抵挡 (星熊 战术装甲, 拉普兰德 日晷) still negates a 无来源 burst — it has no source condition.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { ELEMENT, ELEMENT_GAUGE_MAX, ELEMENT_GAUGE_MAX_LEADER } from '../../server/sim/constants.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const WALL = (id, o = {}) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: o.res ?? 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const NOATK = () => ({ trait: { noAttack: true } });
const CHESS = { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_wall3: WALL('t_wall3'), t_wall4: WALL('t_wall4') };
const KITS = { t_wall: NOATK, t_wall2: NOATK, t_wall3: NOATK, t_wall4: NOATK };
/** Real enemies with their content (enemies.js / bosses.js) against inert synthetic walls. */
const arena = (o = {}) => makeBattle({
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: CHESS }, kits: KITS, captureNoisy: true, hooks: ['elementBurst', 'damaged'], ...o,
});
/** Spawn a pinned enemy (speed ×0) at a tile. */
const put = (h, key, pos, mods = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...mods } });
const elemHits = (h, target, source = null) => h.hooksOf('damaged').filter((c) => c.type === 'element' && c.target === target && (!source || c.source === source));
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, ...o });
const SEA = Object.values(E).filter((e) => e.tags.includes('seamonster'));
test('海嗣 enemies take element damage at the official rate: 损伤抵抗 0, gauge 1000 (leaders 2000), 元素抗性 0 on the burst', () => {
assert.ok(SEA.length >= 10, `${SEA.length} 海嗣 enemies in data`);
for (const rec of SEA) {
assert.equal(rec.stats.elementRes, 0, `${rec.name}: enemy_database epResistance (损伤抵抗) 0`);
assert.equal(rec.stats.elementDmgRes, 0, `${rec.name}: epDamageResistance (元素抗性) 0`);
const h = arena();
h.step();
const e = put(h, rec.key, [11, 8]);
assert.equal(e.gaugeMax, rec.rank === 'BOSS' ? ELEMENT_GAUGE_MAX_LEADER : ELEMENT_GAUGE_MAX, rec.name);
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 250 });
approx(e.elem.burn, 250, 1e-9, `${rec.name}: no hidden reduction`);
if (rec.rank === 'BOSS' || e.bossPool) continue;
const hp0 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
assert.ok(e.findBuff('burnBurst'), `${rec.name} bursts`);
approx(hp0 - e.hp, Math.min(hp0, ELEMENT.burn.enemy.elemDamage), 1e-9, `${rec.name}: 7000 元素伤害`);
}
});
test('海嗣 element attackers deal ATK × the official ratio per hit (PRTS talents), with the round\'s ATK multiplier', () => {
const cases = [
['enemy_1148_dssbr', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 底海滑动者 普通攻击额外造成攻击力15%的神经损伤
['enemy_1148_dssbr_2', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 富营养的滑动者 15 %
['enemy_2021_syfish', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 5]], // 骨海漂流体 攻击附加相当于攻击力20%的侵蚀损伤
['enemy_2025_syufo', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 7]], // 掠海漂移体 普通攻击附加攻击力50%的侵蚀损伤 (ranged 2.6)
['enemy_1234_dsubrl', 'neural', 'EpDamage.ep_damage_ratio', [9, 6]], // 深溟巢涌者 每次输出伤害时,再造成攻击力5%的神经损伤
['enemy_1521_dslily', 'neural', 'epdamage.attack@ep_damage_ratio', null], // 盐风主教昆图斯 攻击力20%的神经损伤
];
for (const [key, el, k, pos] of cases) {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = pos ? put(h, key, pos, { atkMul: 1.331 }) : h.spawn(key, { pos: [9, 7], routeIndex: 0, mods: { speedMul: 0, atkMul: 1.331 } });
approx(e.s.atk, E[key].stats.atk * 1.331, 1e-9, `${E[key].name} ATK × round multiplier`);
const w = h.unit('t_wall');
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 30), `${E[key].name} hits the wall`);
const first = elemHits(h, w, e)[0];
assert.equal(first.dmg.element, el);
approx(first.amount, e.s.atk * E[key].talents.bb[k], 1e-9, `${E[key].name}: ATK × ${E[key].talents.bb[k]}`);
}
// 元核孽生者 (season override: ATK 400, 1.ep_damage_ratio 0.05): once hurt, every second, radius 2.5
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = put(h, 'enemy_1439_dslntf', [9, 7]);
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
h.run(3.05);
const w = h.unit('t_wall');
const hits = elemHits(h, w, e);
assert.equal(hits.length, 3, 'one pulse per second');
approx(hits[0].amount, 400 * 0.05, 1e-9);
});
test('the user\'s case in numbers: blocking a 底海滑动者 (险境 R5, ATK ×0.968) bursts 神经 on the 25th hit — the official rate', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = put(h, 'enemy_1148_dssbr', [9, 5], { atkMul: 0.968 });
const w = h.unit('t_wall');
const per = 280 * 0.968 * 0.15;
const need = Math.ceil(ELEMENT_GAUGE_MAX / per);
assert.equal(need, 25);
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').some((c) => c.target === w), 120), 'the wall bursts');
assert.equal(elemHits(h, w, e).length, need, `${need} hits of ${per.toFixed(1)}`);
approx(h.b.time, need * E.enemy_1148_dssbr.stats.bat, 0.2, '≈ 25 attacks × 2 s (game s)');
});
test('元素损伤倍率 (elemTakenMul) scales the gauge only; 元素脆弱 (elementalTakenMul) the 元素伤害 only', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'elemFragile', { duration: 60, value: 0.2 });
approx(e.s.elementalTakenMul, 1.2);
approx(e.s.elemTakenMul, 1, 1e-9, '元素脆弱 ("受到的元素伤害提升") leaves the gauge alone');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
approx(e.elem.burn, 500, 1e-9, 'gauge fill not × 元素脆弱');
const hp0 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage * 1.2, 1e-9, '7000 元素伤害 × 元素脆弱');
// "受到的元素损伤提高至130%" (淤困) / "…降低12%" (operators' talents): the gauge, not 元素伤害
const h2 = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h2.step();
const e2 = h2.enemy('enemy_dummy');
h2.b.addBuff(e2, { key: 't:ep', mods: { elemTakenMul: 1.3 } });
h2.b.dealDamage(null, e2, { type: 'element', element: 'burn', amount: 500 });
approx(e2.elem.burn, 650);
const hp1 = e2.hp;
h2.b.dealDamage(null, e2, { amount: 100, type: 'elemental', element: 'burn' });
approx(hp1 - e2.hp, 100, 1e-9, '元素伤害 not × 元素损伤倍率');
});
test('损伤抵抗 (epResistance) reduces the gauge; 元素抗性 (epDamageResistance) reduces 元素伤害 — both with the 5 % floor (PRTS 游戏数据基础)', () => {
const rec = dummy({ key: 'enemy_res' });
rec.stats.elementRes = 10;
rec.stats.elementDmgRes = 30;
const hard = dummy({ key: 'enemy_hard' });
hard.stats.elementDmgRes = 100;
// "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可": max(0.05A, A(1 − 损伤抵抗 %))
hard.stats.elementRes = 98;
const h = makeBattle({ defs: { enemies: { enemy_res: rec, enemy_hard: hard } }, enemies: [{ key: 'enemy_res', pos: [11, 8] }, { key: 'enemy_hard', pos: [10, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_res');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
approx(e.elem.burn, 450, 1e-9, '× (1 − 10 %)');
const hp0 = e.hp;
h.b.dealDamage(null, e, { amount: 1000, type: 'elemental' });
approx(hp0 - e.hp, 700, 1e-9, '元素伤害 × (1 − 30 %)');
const x = h.enemy('enemy_hard');
const hp1 = x.hp;
h.b.dealDamage(null, x, { amount: 1000, type: 'elemental' });
approx(hp1 - x.hp, 50, 1e-9, 'at least 5 %');
h.b.dealDamage(null, x, { type: 'element', element: 'burn', amount: 100 });
approx(x.elem.burn, 5, 1e-9, 'the gauge takes at least 5 % too (损伤抵抗 98 → 5, not 2)');
// content that predicts the pipeline (enemies.js expectedFinal — 吸收屏障 etc.) uses the same 元素伤害 terms
h.b.addBuff(e, { key: 't:frag', mods: { elementalTakenMul: 1.2 } });
const d = h.b.makeDamage({ amount: 1000, type: 'elemental' });
const hp2 = e.hp;
const f = enemiesMod.expectedFinal(null, e, d);
approx(f, 1000 * 0.7 * 1.2, 1e-9, 'expectedFinal: 元素抗性 30 and 元素脆弱 20 %');
h.b.dealDamage(null, e, d);
approx(hp2 - e.hp, f, 1e-9, '= what the pipeline deals');
});
test('element bursts deal 无来源 damage: the filler\'s damage-dealt multipliers and penetration never apply; it keeps the credit', () => {
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 50, blockCnt: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: 't_op', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false,
});
h.step();
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
h.b.addBuff(u, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
h.b.addBuff(e, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
const hp0 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×2');
approx(u.stats.dmg, ELEMENT.burn.enemy.elemDamage, 1e-9, 'credited to the operator that filled the gauge');
const hp1 = u.hp;
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 1000 });
approx(hp1 - u.hp, ELEMENT.burn.ally.damage * (1 - (50 - ELEMENT.burn.ally.resDown) / 100), 1e-9, '1200 arts through RES 50 − 20, no penetration, not ×3');
// apoptosis ticks on enemies too
h.run(10.1);
const hp2 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'apoptosis', amount: 1000 });
h.run(3.02);
approx(hp2 - e.hp, 3 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
});
test('无来源 reaches the content hooks too: `hit` / `damaged` get no source, `credit` and the kill keep the filler (PRTS 伤害分类)', () => {
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy(), enemy_small: dummy({ key: 'enemy_small', hp: 5000 }) } },
units: [{ chessId: 't_op', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_small', pos: [10, 8] }],
content: 'none', autoFinish: false, hooks: ['hit', 'damaged', 'kill'], captureNoisy: true,
});
h.step();
const u = h.unit('t_op'), e = h.enemy('enemy_dummy'), small = h.enemy('enemy_small');
// a content hook keyed on the attacker ("攻击…时攻击力提升至150%" style) — it must not see the burst
h.b.on('hit', (c) => { if (c.source === u && c.target.side === 'enemy') c.dmg.mul *= 1.5; });
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
approx(1e6 - e.hp, 1500, 1e-9, 'the hook works on the operator\'s own damage');
const hp0 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×1.5');
const burstHit = h.hooksOf('hit').find((c) => c.dmg.sourceless && c.target === e);
assert.equal(burstHit.source, null, 'hit: no source');
assert.equal(burstHit.credit, u, 'hit: credit');
const burstDmg = h.hooksOf('damaged').find((c) => c.dmg && c.dmg.sourceless && c.target === e);
assert.equal(burstDmg.source, null, 'damaged: no source');
assert.equal(burstDmg.credit, u, 'damaged: credit');
const gauge = h.hooksOf('damaged').find((c) => c.type === 'element' && c.target === e);
assert.equal(gauge.source, u, 'the 元素损伤 itself keeps its source');
// "无来源伤害的击杀也能追溯击杀来源"
h.b.dealDamage(u, small, { type: 'element', element: 'burn', amount: 1000 });
assert.equal(small.alive, false);
assert.equal(h.hooksOf('kill').find((c) => c.victim === small)?.killer, u, 'kill credited to the filler');
});
test('the user\'s pairing: a real 深巡 (garrison "攻击海怪敌人时攻击力提升至150%") bursting a real 海嗣 deals 7000, not 10500', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_1_04_a', row: 10, col: 3 }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3 });
h.step();
const u = h.allies()[0];
assert.deepEqual(u.def.raw.garrisonIds, ['garrison_18_a']);
const e = put(h, 'enemy_1439_dslntf', [11, 8]);
assert.ok(e.def.tags.includes('seamonster'), '元核孽生者 is a 海怪');
const hp0 = e.hp;
h.b.dealDamage(u, e, { amount: 100, type: 'true' });
approx(hp0 - e.hp, 100 * 1.5 * u.s.dmgDealtMul, 1e-9, 'its own damage vs a 海怪: ×1.5');
const hp1 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
assert.ok(e.findBuff('burnBurst'));
approx(hp1 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, 'the burst is 无来源: 7000');
});
test('精准狙击镜 (×damage_scale at ≥ 3 tiles) boosts its carrier\'s hits, never the burst it causes', () => {
const op = chessRec({ id: 't_op', profession: 'SNIPER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 't_op', row: 10, col: 3, items: ['chess_item_3_02_e_a'] }],
enemies: [{ key: 'enemy_dummy', pos: [10, 8] }], content: 'full', autoFinish: false, timeLimit: 600,
});
h.step();
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
const boosted = (1e6 - e.hp) / u.s.dmgDealtMul;
assert.ok(boosted > 1000 + 1e-6, `the scope works at range (${boosted})`);
const hp0 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'erosion', amount: 1000 });
approx(hp0 - e.hp, ELEMENT.erosion.enemy.elemDamage, 1e-9, '5000 侵蚀 burst, not × the scope');
});
test('a 无来源 burst still passes on through content that shares damage: 圣杯 (credited, no source multiplier) and 盲信之誓', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3 }] });
h.step();
const w = h.unit('t_wall');
h.b.addBuff(w, { key: 't:boost', mods: { dmgDealtMul: 2 } });
const c = put(h, 'enemy_1430_lrrook', [10, 7]);
const o = put(h, 'enemy_1439_dslntf', [10, 6]);
const share = E.enemy_1430_lrrook.talents.bb['takeDmg.damage_scale'];
const c0 = c.hp, o0 = o.hp;
h.b.dealDamage(w, o, { type: 'element', element: 'burn', amount: o.gaugeMax });
const burst = ELEMENT.burn.enemy.elemDamage;
approx(c0 - c.hp, Math.min(c0, burst * share), 1e-9, 'the chalice takes its share of 7000 (not ×2)');
approx(o0 - o.hp, Math.min(o0, burst * (1 - share)), 1e-9);
approx(w.stats.dmg, (c0 - c.hp) + (o0 - o.hp), 1e-9, 'both parts credited to the filler');
// 盲信之誓: a spring passes PART_TRANSFER of what it takes to 假想敌:铳 — a burst too
const hb = arena({ kind: 'boss', sharedBoss: { hp: 1e6, maxHp: 1e6, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } }, units: [{ chessId: 't_wall', row: 12, col: 3 }] });
hb.step();
const g = hb.spawn('enemy_9017_achunt_2', { pos: [3, 18], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
g.profile.noAttack = true;
assert.ok(g.bossPool, '铳 on the shared pool');
const sp = hb.spawn('enemy_9020_actrpc', { pos: [3, 4], routeIndex: 0, mods: { speedMul: 0 }, tag: 'part' });
const pool0 = hb.b.sharedBoss.hp, sp0 = sp.hp;
hb.b.dealDamage(hb.unit('t_wall'), sp, { type: 'element', element: 'burn', amount: sp.gaugeMax });
assert.ok(sp0 - sp.hp > 0, 'the spring takes the burst');
approx(pool0 - hb.b.sharedBoss.hp, (sp0 - sp.hp) * bossesMod.PART_TRANSFER, 1e-9, '…and passes its share to 铳');
});
test('凋亡 burst on an enemy: 800 元素伤害 per second, the 50 % 虚弱 drops to 50 % × 剩余时间 ÷ 15 s at each tick (PRTS 元素)', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ atk: 1000 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_dummy');
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
approx(e.s.atk, 500, 1e-9, '50 % at once');
for (const t of [1, 5, 14]) {
h.run(t - (h.b.time - 1 / 30) + 0.02);
const left = ELEMENT.apoptosis.enemy.duration - t;
assert.ok(Math.abs(e.s.atk - 1000 * (1 - 0.5 * left / ELEMENT.apoptosis.enemy.duration)) < 3, `${t} s: ATK ${e.s.atk.toFixed(1)}`);
}
approx(1e6 - e.hp, 14 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
});
test('麻痹免疫 enemies get no 麻痹 from a 神经 burst (PRTS 元素 "若单位不具有麻痹免疫") — still the 6000 元素伤害', () => {
const imm = dummy({ key: 'enemy_imm', otherImmunities: ['palsy'] });
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy(), enemy_imm: imm } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_imm', pos: [10, 8] }], content: 'none', autoFinish: false });
h.step();
const a = h.enemy('enemy_dummy'), b = h.enemy('enemy_imm');
for (const e of [a, b]) h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 });
assert.equal(a.findBuff('palsy')?.stacks, ELEMENT.neural.enemy.palsy);
assert.equal(b.findBuff('palsy'), null, 'immune');
approx(1e6 - b.hp, ELEMENT.neural.enemy.elemDamage);
// a real one: 假想敌:铳 (palsyImmune)
assert.ok(E.enemy_9017_achunt.stats.otherImmunities.includes('palsy'));
});
test('假想敌:淤困: its host\'s burst deals 1000 of the same element to the allies of the 4 tiles around (PRTS)', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 10, col: 6 }, { chessId: 't_wall4', row: 11, col: 6 }] });
h.step();
put(h, 'enemy_9007_acelem', [10, 5]);
h.run(0.5);
const host = h.unit('t_wall');
assert.ok(host.findBuff('ab:parasite'), 'parasitised');
h.b.dealDamage(null, host, { type: 'element', element: 'neural', amount: 1000 });
const spread = (u) => elemHits(h, u).filter((c) => c.dmg.element === 'neural');
for (const id of ['t_wall2', 't_wall3']) {
const s = spread(h.unit(id));
assert.equal(s.length, 1, `${id} (next to the host) takes one spread`);
approx(s[0].amount, 1000, 1e-9, `${id}: 1000`);
assert.ok(h.unit(id).findBuff('neuralBurst'), `${id} bursts`);
}
assert.equal(spread(h.unit('t_wall4')).length, 0, 'the diagonal tile is not one of the 4');
});
test('灼藤: its first attack burns and hits every other ally of the target\'s 3×3 (x-4), diagonals included', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 9, col: 5 }, { chessId: 't_wall4', row: 12, col: 5 }] });
h.step();
const v = put(h, 'enemy_10067_ftsjc', [10, 5]);
assert.ok(h.runUntil(() => v.stats.attacks >= 1, 10), 'first attack');
const atk = elemHits(h, h.unit('t_wall'), v)[0].amount / E.enemy_10067_ftsjc.talents.bb['pow.attack@ep_damage_ratio'];
for (const id of ['t_wall2', 't_wall3']) {
const u = h.unit(id);
approx(elemHits(h, u, v)[0]?.amount ?? 0, atk * 0.25, 1e-6, `${id}: 25 % burn`);
assert.ok(u.stats.taken > 0, `${id}: 100 % arts splash`);
}
assert.equal(elemHits(h, h.unit('t_wall4'), v).length, 0, 'two rows away: outside');
});
test('节日爵士乐手 狂欢式演奏: channels on ONE locked target (20 % arts + 10 % burn every 0.5 s), not on everyone around', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 11, col: 5 }] });
h.step();
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
h.run(6);
const w = h.unit('t_wall');
const hits = elemHits(h, w, e);
assert.ok(hits.length >= 4, `the locked target burns (${hits.length})`);
approx(hits[0].amount, e.s.atk * 0.1, 1e-9);
for (const id of ['t_wall2', 't_wall3']) assert.equal(elemHits(h, h.unit(id), e).length, 0, `${id} is not the target`);
});
test('节日爵士乐手: channels never overlap (cooldown 10 s < channel 10.6 s) — 21 ticks each, no normal attack while channelling', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
h.step();
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
const w = h.unit('t_wall');
const sk = E.enemy_10034_cnvsax.skills.find((x) => x.prefabKey === 'fire');
const iv = sk.bb.hit_interval, n = Math.floor(sk.bb['enemy_cnvsax[cd].duration'] / iv + 1e-9);
assert.equal(n, 21, 'floor(10.6 / 0.5)');
const arts = [], during = [];
h.b.on('damaged', (c) => {
if (c.target !== w || c.source !== e) return;
if (c.type === 'arts') arts.push(h.b.time);
else if (c.type === 'phys' && e.findBuff('ab:channel')) during.push(h.b.time);
});
const skill = e.mem.ab.list.find((a) => a && a.fire && a.cd != null);
const casts = [];
for (let i = 0; i < 30 * 30; i++) {
h.step();
if ((skill.casts ?? 0) > casts.length) casts.push(h.b.time);
}
assert.ok(casts.length >= 2, `cast twice in 30 s (${casts.map((t) => t.toFixed(2))})`);
for (let i = 1; i < arts.length; i++) assert.ok(arts[i] - arts[i - 1] >= iv - 0.02, `no doubled tick at ${arts[i].toFixed(2)}`);
for (let i = 0; i + 1 < casts.length; i++) {
const k = arts.filter((t) => t > casts[i] && t <= casts[i + 1]).length;
assert.equal(k, n, `cast ${i + 1}: ${n} ticks`);
assert.ok(casts[i + 1] - casts[i] >= n * iv - 1e-6, 'the next cast waits for the channel');
}
assert.deepEqual(during, [], 'no normal attack while a channel runs');
});
test('抵挡 is target-side: 星熊 (战术装甲) and 拉普兰德 S1 日晷 still negate a 无来源 burst on themselves (source null, credit = the enemy)', async () => {
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const ds = getDefaultSource();
const altIdx = (id) => ds.rawChess(id).skills.find((s) => !s.isDefault).index;
const cases = [
['chess_char_4_17_a', {}, ['erosion', 'burn']], // 星熊: phys (侵蚀 800) and arts (灼燃 1200)
['chess_char_2_16_a', { skillIndex: altIdx('chess_char_2_16_a'), carryState: { sp: 999 } }, ['erosion']], // 日晷: phys only
];
for (const [id, extra, elements] of cases) {
for (const roll of [true, false]) {
for (const element of elements) {
const h = makeBattle({
units: [{ chessId: id, row: 9, col: 5, ...extra }], defs: { enemies: { enemy_dummy: dummy() } },
enemies: [{ key: 'enemy_dummy', pos: [9, 5] }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3, // blocked: in range
setup: (b) => { b.rng.chance = () => roll; },
});
h.step();
const u = h.unit(id), e = h.enemy('enemy_dummy');
if (extra.skillIndex != null) assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: 日晷 on`);
const hp0 = u.hp;
h.b.dealDamage(e, u, { type: 'element', element, amount: u.gaugeMax });
assert.ok(u.findBuff(`${element}Burst`), `${id} ${element}: bursts`);
if (roll) assert.equal(u.hp, hp0, `${id} ${element}: the burst is 抵挡-ed`);
else assert.ok(u.hp < hp0, `${id} ${element}: a failed roll lands`);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
}
}
});
test('卢西恩 / 不祥幻影 in their only stage (boss_5 = act2autochess_h07_05 / _s): the level\'s overrides — ATK 700 (solo 600) / 200, 12 % 神经 per hit', () => {
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
// level_act2autochess_h07_05(_s).json enemyDbRefs[5] / [7] overwrittenData: atk 700 (600) / 200,
// combat.attack@ep_damage_ratio 0.12
for (const [tpl, key, atk] of [['act2autochess_h07_05', 'enemy_2016_csphtm', 700], ['act2autochess_h07_05_s', 'enemy_2016_csphtm', 600], ['act2autochess_h07_05', 'enemy_2017_csphts', 200]]) {
const h = arena({
kind: 'boss', sharedBoss: { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } },
units: [{ chessId: 't_wall', row: 10, col: 5 }], setup(b) { b.enemyOverrides = W[tpl].overrides; },
});
h.step();
const w = h.unit('t_wall');
// blocked on the wall's tile: its first normal attack lands before the 5 s initial cooldown of its aoe skill (0.2)
const e = h.spawn(key, { pos: [w.y, w.x].map(Math.round), routeIndex: 0, mods: { speedMul: 0 }, tag: key === 'enemy_2016_csphtm' ? 'boss' : undefined });
assert.equal(e.s.atk, atk, `${tpl} ${E[key].name}: ATK`);
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 4.5), `${E[key].name} hits`);
const first = elemHits(h, w, e)[0];
assert.equal(first.dmg.element, 'neural');
approx(first.amount, atk * 0.12, 1e-9, `${tpl} ${E[key].name}: ATK × 0.12 (base data: ${E[key].stats.atk} × ${E[key].talents.bb['combat.attack@ep_damage_ratio']})`);
}
});
+7 -3
View File
@@ -392,6 +392,10 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
};
});
// The field's 3D area ends at its separator row 13 — the row-13 devices blow into the field (act2 m01's blowers, user
// playtest #5 item 6: render/app.js boardArea) — and the enemy preview pen starts at row 14.
const NO_PEN_R1 = 13;
test('6: the enemy pen shows only in the pen view — never with the prep board (after 返回战场) or any battle', { skip: skipUnless('pen'), timeout: 5 * 60 * 1000 }, async () => {
const { page, problems } = await open('phase=PREP', { w: 1920, h: 1080 });
let s = await penState(page);
@@ -399,7 +403,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
assert.ok(s.pen > 0, 'the pen holds the next round\'s enemies');
assert.equal(s.penVisible, 0, 'prep board: no pen figure shown');
assert.equal(s.penRowsDrawn, false, 'prep board: pen rows not drawn');
if (s.board3d) assert.ok(s.area3d <= 12, `prep board: 3D area without the pen (${s.area3d})`);
if (s.board3d) assert.ok(s.area3d <= NO_PEN_R1, `prep board: 3D area without the pen (${s.area3d})`);
await page.screenshot({ path: path.join(OUT, 'fix-pen-prep.png') });
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
@@ -416,7 +420,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
s = await penState(page);
assert.equal(s.penVisible, 0, 'back on the board (返回战场): no pen figure');
assert.equal(s.penRowsDrawn, false, 'back on the board: pen rows not drawn');
if (s.board3d) assert.ok(s.area3d <= 12, 'back on the board: no pen area');
if (s.board3d) assert.ok(s.area3d <= NO_PEN_R1, `back on the board: no pen area (${s.area3d})`);
await page.screenshot({ path: path.join(OUT, 'fix-pen-back.png') });
assert.deepEqual(problems, []);
await page.close();
@@ -428,7 +432,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
const b = await penState(o.page);
assert.equal(b.penVisible, 0, `${phase}: no pen figure`);
assert.equal(b.penRowsDrawn, false, `${phase}: pen rows not drawn`);
if (b.board3d) assert.ok(b.area3d <= 12, `${phase}: no pen area (${b.area3d})`);
if (b.board3d) assert.ok(b.area3d <= NO_PEN_R1, `${phase}: no pen area (${b.area3d})`);
await o.page.screenshot({ path: path.join(OUT, `fix-pen-${phase.toLowerCase()}.png`) });
assert.deepEqual(o.problems, [], phase);
await o.page.close();
+5 -1
View File
@@ -201,12 +201,16 @@ async function untilPrep(c, pred, what, timeout = 90000) {
}
}
// The field's 3D area ends at its separator row 13 — the row-13 devices blow into the field (act2 m01's blowers, user
// playtest #5 item 6: render/app.js boardArea) — and the enemy preview pen starts at row 14.
const NO_PEN_R1 = 13;
function assertNoPen(v, tag) {
assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`);
assert.equal(v.penVisible, 0, `${tag}: no pen figure`);
assert.equal(v.penRowsDrawn, false, `${tag}: no pen rows drawn (${v.band})`);
assert.ok(v.band && v.band[1] <= 13, `${tag}: drawn rows ${v.band}`);
if (v.area3d != null) assert.ok(v.area3d <= 12, `${tag}: 3D area without the pen block (${v.area3d})`);
if (v.area3d != null) assert.ok(v.area3d <= NO_PEN_R1, `${tag}: 3D area without the pen block (${v.area3d})`);
}
describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more