diff --git a/server/sim/battle/players.js b/server/sim/battle/players.js index 04b73486..5f658f0b 100644 --- a/server/sim/battle/players.js +++ b/server/sim/battle/players.js @@ -3,6 +3,7 @@ // Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle). import { COLS, BOSS_ROW_OFFSET } from '../constants.js'; +import { GEO } from '../../../shared/constants.js'; import { Unit } from '../units.js'; import { normDir, mirrorDir } from '../dir.js'; import { SkillRuntime } from '../skills.js'; @@ -70,6 +71,29 @@ export class BattlePlayers { /** Board → field tile for a player (see header). */ mapTile(ps, row, col, abs = false) { if (abs || ps.coords === 'field') return [row, col]; + return this._boardTile(ps, row, col); + } + + /** + * Whether field tile (r, c) lies on player `ps`'s own board: the board region GEO.FIELD (rows 9–12, cols 2–10, the + * tiles its pieces are deployed on) mapped onto this field — the 联防 right-hand helper's +8 columns, the boss field's + * rows 2–5, the mirrored right half of a pair (whatever coordinates the player's input uses). Never the hand / 临时 + * 整备区 rows of a boss field (rows 0–1, inside BOSS_RECT) nor the other half of a 联防 or boss field. Automatic + * redeployments that must stay where the player could deploy read it (the 突袭 landing, bonds/addon/battle.js raidTile). + */ + onOwnBoard(ps, r, c) { + if (!ps) return false; + let keys = ps._boardKeys; + if (!keys) { + keys = ps._boardKeys = new Set(); + for (let br = GEO.FIELD.r0; br <= GEO.FIELD.r1; br++) { + for (let bc = GEO.FIELD.c0; bc <= GEO.FIELD.c1; bc++) { const [fr, fc] = this._boardTile(ps, br, bc); keys.add(fr * COLS + fc); } + } + } + return keys.has(r * COLS + c); + } + + _boardTile(ps, row, col) { const bossLike = this.kind === 'boss' || this.kind === 'hidden'; let r = row; if (ps.rowOffset != null) r = row + ps.rowOffset; diff --git a/server/sim/content/kits/ops/chess_char_5_05-ulpia.js b/server/sim/content/kits/ops/chess_char_5_05-ulpia.js index 1d7a8a11..0d192351 100644 --- a/server/sim/content/kits/ops/chess_char_5_05-ulpia.js +++ b/server/sim/content/kits/ops/chess_char_5_05-ulpia.js @@ -13,7 +13,8 @@ export default { // --------------------------------------------------------------------------------------------------------------- // 乌尔比安 — S3 必须开辟的通路 (25 s, CUSTOM_RANGE row ahead): max HP/ATK +, throws an anchor forward that stops on the // first enemy or at max distance — on his own tile while he blocks: 135 % ATK phys + 6 s stun around it - // (projectile_range); moves onto the anchor tile when deployable and not his own (a 从不混淆的方向 marker keeps his tile) + // (projectile_range); moves onto the anchor tile when deployable (a tile of his own board) and not his own (a 从不混淆的方向 + // marker keeps his tile) // and returns at skill end — both 【移动】, i.e. free redeploys (Battle.moveRedeploy; the return empties his SP). // 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. @@ -90,8 +91,12 @@ export default { // anchor on his own tile leaves him where he stands, no marker, nothing to return from [ASSUMED: the "tile one // beyond the landing" is not tried when the landing is his own tile] if (stop === 0 || !unit.alive) return; - // PRTS 备注: landing tile > the tile one beyond it > his own tile (a deployable, free, unreserved melee tile) - const ok = ([r, c]) => (r !== unit.tileR || c !== unit.tileC) && battle.grid.inRect(r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c); + // PRTS 备注: landing tile > the tile one beyond it > his own tile (a deployable, free, unreserved melee tile). + // "可部署" is a tile his player may deploy on: his own board (Battle.onOwnBoard) — never the other half of a 联防 or + // boss field nor a boss field's hand / 临时整备区 rows. Community report of 2026-10-06 (item 27) 「乌尔比安使用3技能会在 + // 联防阶段跳到红门后」: on the one-helper 联防 map (escaped_single, enemies from the middle gate at col 10) an anchor that + // met no enemy flew on to the right half, where no enemy ever walks, and he moved there for the whole skill + const ok = ([r, c]) => (r !== unit.tileR || c !== unit.tileC) && battle.grid.inRect(r, c) && battle.onOwnBoard(unit.player, r, c) && battle.grid.canStand(r, c) && !battle.grid.isObstacle(r, c) && !battle.isReservedTile(r, c); const dest = [[sr, sc], frontOf(unit.tileR, unit.tileC, unit.dir, stop + 1)].find(ok); if (dest == null) return; const home = [unit.tileR, unit.tileC]; diff --git a/test/golden/fields.json b/test/golden/fields.json index 35fc2c3b..7dba91bb 100644 --- a/test/golden/fields.json +++ b/test/golden/fields.json @@ -1190,38 +1190,37 @@ "killed": 3, "total": 3, "unspawned": 0, - "bossHpLeft": 1870565, - "rngDraws": 124, + "bossHpLeft": 1903562, + "rngDraws": 67, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":3,"total":3,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2969443,"bossDmg":929435,"heal":76069,"deaths":5,"layerGains":{}}}, + "players": {"p1":{"killed":3,"total":3,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2843148,"bossDmg":896438,"heal":73550,"deaths":3,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,1,0,377438,9366,0,127,4], - ["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4794.8,1,3,0,832,3417,28568,44,33], - ["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",10629,1,36,0,414083,36990,19701,39,5], - ["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",2567.95,1,7,1,1490203,17115,16047,201,5], - ["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3364.8,1,9,1,118420,12547,3364,79,3], - ["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",2524.94,1,39,0,15153,19432,3930,10,2], - ["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1439,1,5,0,0,0,0,0,0], - ["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",968.1,1,6,0,289738,1471,967,189,6], - ["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1480,1,35,0,19621,10891,3491,28,6], - ["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1787,1,2,1,243954,0,0,53,41], - ["p1","~1","token","token_10039_ulpia_block",null,null,3485,0,17,0,0,15,0,0,null], - ["p1","~2","token","token_10039_ulpia_block",null,null,3350,0,24,0,0,150,0,0,null], - ["p1","~3","token","token_10039_ulpia_block",null,null,3335,0,31,0,0,165,0,0,null] + ["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2698.4,1,1,0,324837,9374,0,127,4], + ["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4804.8,1,3,0,848,2750,31550,44,34], + ["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",6503.23,1,31,0,255230,37295,14964,28,4], + ["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",3543.16,1,7,2,1502942,13705,13608,201,5], + ["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3364.8,1,9,1,107792,11970,2438,65,2], + ["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",238.16,1,25,0,8629,21618,5871,17,2], + ["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1439,1,5,0,6082,0,2705,17,1], + ["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",351.58,1,6,0,364837,2591,1471,188,6], + ["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1480,1,24,0,15428,5383,943,40,5], + ["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1777,1,2,0,256524,80,0,53,41], + ["p1","~1","token","token_10039_ulpia_block",null,null,3350,0,20,0,0,150,0,0,null], + ["p1","~2","token","token_10039_ulpia_block",null,null,3335,0,28,0,0,165,0,0,null] ], "enemies": { "enemy_10067_ftsjc": [1,1,0,1161,21565], - "enemy_5601_entlec": [7,6,0,0,174538], - "enemy_9007_acelem": [2,2,0,747,119439], - "enemy_9017_achunt_2": [1,0,0,33211,929435], - "enemy_9018_actrpa": [1,0,0,16277,517062], - "enemy_9019_actrpb": [1,0,0,18530,477352], - "enemy_9020_actrpc": [1,0,0,15909,730051] + "enemy_5601_entlec": [6,6,0,0,166866], + "enemy_9007_acelem": [2,2,0,1054,119439], + "enemy_9017_achunt_2": [1,0,0,28371,896438], + "enemy_9018_actrpa": [1,0,0,14779,508719], + "enemy_9019_actrpb": [1,0,0,19659,453678], + "enemy_9020_actrpc": [1,0,0,17888,676442] }, - "hooks": [1,40,6000,876,876,3547,8777,6397,14,19,105,102,118,25207,1875,10,14,0,1,109,0,2,0], - "events": {"atk":1409,"deploy":26,"die":19,"dmg":8204,"engage":9,"fx":850,"heal":383,"skill":207,"spawn":27,"status":303}, - "fx": {"anchor":5,"aoe":2,"beam":20,"burst":2,"candle":7,"charge":7,"dash":12,"downed":1,"ember":2,"explode":1,"healAoe":33,"link":597,"overload":4,"palsy":4,"phase":12,"revive":1,"shieldBreak":10,"splash":118,"substitute":3,"swap":3,"teleport":6}, - "snaps": "37ac48f3 092a4bc2 41b7e5d4 56d6fd20 79e6de1e 5c8059ed c19e7138 677e05fa cbd43f62 8f0f65b8 4d76189a 297c0633 3bc4d3c3 9ff783eb 79b3ec5e 5c6f5ce5 a8d3399b 8de60a18 1eaa3427 f2260143" + "hooks": [1,33,6000,894,894,3465,8457,4295,12,15,104,100,124,25057,1653,12,13,0,1,78,0,2,0], + "events": {"atk":1389,"deploy":20,"die":15,"dmg":7934,"engage":10,"fx":855,"heal":375,"skill":204,"spawn":25,"status":337}, + "fx": {"anchor":4,"aoe":2,"beam":24,"burst":2,"candle":6,"charge":7,"dash":12,"downed":1,"ember":1,"explode":1,"healAoe":34,"link":597,"overload":4,"palsy":2,"phase":12,"revive":1,"shieldBreak":11,"sleepGuard":1,"splash":124,"substitute":2,"swap":2,"teleport":4,"wake":1}, + "snaps": "37ac48f3 f657c2f6 e25b0f01 5ea69d76 c3a7469d 9558529b 0fd83c8e 2c035602 b69af21f c2a0eb54 b31f2ad7 8b64e08b bbbd8e09 0dca9aae ce7aae50 b0cd3d8c 35382dd2 d3b4e413 db5e9c68 41e7d434" }, "hidden-boss_10-pair": { "about": "假想敌:管 pair pool 15200000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld", diff --git a/test/sim/facing-invariance.test.js b/test/sim/facing-invariance.test.js index a011bb6b..cc207a98 100644 --- a/test/sim/facing-invariance.test.js +++ b/test/sim/facing-invariance.test.js @@ -48,7 +48,12 @@ function scenario(chessId, dir, secs) { e_fly: enemyRec({ key: 'e_fly', hp: 1e6, speed: 0, motion: 'FLY', def: 50, res: 30 }), }, }, - setup: (b) => b.on('deploy', ({ unit }) => { if (unit.uid >= 100) unit.hp = unit.s.maxHp * (0.3 + 0.1 * (unit.uid - 100)); }), + setup: (b) => { + b.on('deploy', ({ unit }) => { if (unit.uid >= 100) unit.hp = unit.s.maxHp * (0.3 + 0.1 * (unit.uid - 100)); }); + // the open field has no boards: "a tile of the player's own board" (Battle.onOwnBoard — 乌尔比安 S3's landing) is a + // board-position rule (DESIGN), not a facing one — every tile counts here, so the anchor's landing turns with him + b.onOwnBoard = () => true; + }, }); h.run(secs); checkInvariants(h.b); diff --git a/test/sim/feedback5-ulpia-own-board.test.js b/test/sim/feedback5-ulpia-own-board.test.js new file mode 100644 index 00000000..8f317d62 --- /dev/null +++ b/test/sim/feedback5-ulpia-own-board.test.js @@ -0,0 +1,63 @@ +// test/sim/feedback5-ulpia-own-board.test.js — community report of 2026-10-06 (item 27) 「乌尔比安使用3技能会在联防阶段跳到红门 +// 后」. 乌尔比安 S3 必须开辟的通路 throws the anchor up to 6 tiles ahead (the nearest enemy of the line, else the farthest tile) and +// moves there when the tile "可以部署" (PRTS 备注 ③: the landing tile > the tile one beyond > his own tile). On the one-helper 联防 +// map (act1autochess_escaped_single: the helper's pieces on the left half, the enemies out of the middle gate at col 10) the +// right half is no tile his player deploys on, yet an anchor that met no enemy flew there and he moved behind the red gate for +// the whole skill. Now the landing must lie on his own board (Battle.onOwnBoard, the board region mapped onto the field). +// Run: node --test test/sim/feedback5-ulpia-own-board.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const ULPIA = 'chess_char_5_05_a'; +const dummy = enemyRec({ key: 'enemy_anchor_dummy', hp: 1e9, speed: 0, mass: 0, atk: 0 }); + +/** 乌尔比安 cast S3 on `tile` of `kind`'s field; `enemy` = a parked enemy's tile or null. Returns where he stands after the cast. */ +function cast({ kind = 'unite', stageId = 'act1autochess_escaped_single', tile = [10, 7], enemy = null, colOffset = 0 } = {}) { + const h = makeBattle({ + kind, stageId, seed: 5, autoFinish: false, timeLimit: 120, defs: { enemies: { enemy_anchor_dummy: dummy } }, + players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset, units: [{ uid: 1, kind: 'chess', chessId: ULPIA, row: tile[0], col: tile[1] }], bonds: {} }], + }); + h.step(); + const u = h.unit(ULPIA); + const home = [u.tileR, u.tileC]; + if (enemy) { h.spawn('enemy_anchor_dummy', { pos: enemy }); h.step(); } // (one tick: the enemy joins the tile index) + u.skill.gainSp(999, 'test'); + // (its CUSTOM_RANGE trigger waits for an enemy on the line: with none the cast is made by hand, as a player's would be) + if (!u.skill.active) assert.ok(u.skill.activate('test'), 'S3 cast'); + h.run(0.2); + const at = [u.tileR, u.tileC]; + checkInvariants(h.b); + return { h, u, home, at }; +} + +test('one-helper 联防: an anchor that meets no enemy flies on past the middle gate — he stays on his own half (until 0.2.0 he moved behind the red gate)', () => { + const { h, u, home, at } = cast({ tile: [10, 7] }); + assert.deepEqual(home, [10, 7]); + assert.ok(h.b.rect.c1 >= 13, 'the 联防 field spans both halves'); + assert.deepEqual(at, home, `stays on (10,7) — not ${at}`); + assert.ok(h.b.onOwnBoard(u.player, 10, 7) && !h.b.onOwnBoard(u.player, 10, 13), 'cols 11+ are not his board'); + assert.equal(h.b.allyUnits.filter((a) => a.kind === 'token' && a.alive).length, 0, 'no 从不混淆的方向: nothing to return from'); +}); + +test('one-helper 联防: an enemy on his half still draws the anchor and he moves next to it (own board)', () => { + const { at } = cast({ tile: [10, 4], enemy: [10, 8] }); + assert.deepEqual(at, [10, 8], 'he moves onto the anchor tile on his half'); +}); + +test('two-helper 联防: the left helper never lands on the right helper\'s half; the right helper moves on its own half', () => { + const left = cast({ stageId: 'act1autochess_escaped_multi', tile: [10, 7] }); + assert.deepEqual(left.at, left.home, `left helper stays (${left.at})`); + // the right-hand helper (colOffset 8): board col 3 → field col 11, enemy 3 tiles ahead on its own half + const right = cast({ stageId: 'act1autochess_escaped_multi', tile: [10, 3], colOffset: 8, enemy: [10, 14] }); + assert.deepEqual(right.home, [10, 11]); + assert.deepEqual(right.at, [10, 14], 'moves on its own half'); + assert.ok(right.h.b.onOwnBoard(right.u.player, 10, 14) && !right.h.b.onOwnBoard(right.u.player, 10, 7)); +}); + +test('a normal battle is unchanged: the anchor stops at the field edge and he moves onto the farthest tile of his board', () => { + const { at, h, u } = cast({ kind: 'normal', stageId: 'flat', tile: [10, 3] }); + assert.deepEqual(at, [10, 9], `6 tiles ahead, the last road tile before the gate column (${at})`); + assert.ok(h.b.onOwnBoard(u.player, 10, 9)); +});