fix: 将涤火杰西卡的盾牌放置限制在相邻四格

This commit is contained in:
Xinglan233 committed 2026-10-10 09:52:07 +08:00
1 parent 1db8e51023
commit 85062bab67
11 files changed
+209 -22

No files matched your search

+4 -1
View File
@@ -621,7 +621,10 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
(`PlayerState._legal` / `summonRange`, `board.js ownerRangeKeys`; a summon dragged onto its own owner is checked from (`PlayerState._legal` / `summonRange`, `board.js ownerRangeKeys`; a summon dragged onto its own owner is checked from
the owner's new tile, an operator dragged onto the summon from the summon's new tile); Mon3tr's 重构体 likewise (her the owner's new tile, an operator dragged onto the summon from the summon's new tile); Mon3tr's 重构体 likewise (her
talent), while 凯尔希·思衡托's 战术锚点 (`ownerRangeOutside` / `rangedTilesOnly`) goes only on a 高台 outside that range talent), while 凯尔希·思衡托's 战术锚点 (`ownerRangeOutside` / `rangedTilesOnly`) goes only on a 高台 outside that range
(`summonExcluded`, board.js class `high`). When the owner is re-oriented in (`summonExcluded`, board.js class `high`). 涤火杰西卡's 机动盾牌 instead uses the four orthogonally adjacent tiles
(`shared/summonPlacement.js`, her T1 in backups.json / PRTS 涤火杰西卡 天赋), regardless of her facing, skill or
module; diagonal and distant tiles are refused in prep. The same `summonRange` feeds server placement, swaps, the
client's drag highlights and bot planning. When the owner is re-oriented in
place (or promoted) a summon its new range leaves out goes back onto its stack with a toast (`_liftOutOfRange` in place (or promoted) a summon its new range leaves out goes back onto its stack with a toast (`_liftOutOfRange` in
`recompute`) — one still inside stays [ASSUMED]. A re-orientation that would leave such a summon with no stack and no `recompute`) — one still inside stays [ASSUMED]. A re-orientation that would leave such a summon with no stack and no
free hand / temp slot is refused (HAND_FULL, like withdrawing a summon into a full hand); in the other cases (a free hand / temp slot is refused (HAND_FULL, like withdrawing a summon into a full hand); in the other cases (a
+6
View File
@@ -58,6 +58,12 @@ Intent handlers (validate → mutate → recompute bonds → push private view):
Summons (§20.1): an operator on the board sends its manually deployable summons to the hand — deploy-limit copies in one stack, no deploy slot (tokens.json `placeable`, `gamedata.placeableTokens`); moving or swapping it sends its placed summons back to their stack, withdrawing or selling it removes them. A range-bound summon (tokens.json `ownerRange`: 狼群, 流形) goes only on a tile of its owner's attack range (`PlayerState._legal` / `summonRange`); in a swap each piece is checked from the tile it ends on; an in-place re-orientation or promotion that leaves it outside sends it back to its stack with a toast (one still inside stays [ASSUMED], user-confirmed 2026-10-03), and a re-orientation whose lifted summon would have no room is refused with `HAND_FULL`; on the other paths it leaves the board and returns at the next round start (§21.3). Summons (§20.1): an operator on the board sends its manually deployable summons to the hand — deploy-limit copies in one stack, no deploy slot (tokens.json `placeable`, `gamedata.placeableTokens`); moving or swapping it sends its placed summons back to their stack, withdrawing or selling it removes them. A range-bound summon (tokens.json `ownerRange`: 狼群, 流形) goes only on a tile of its owner's attack range (`PlayerState._legal` / `summonRange`); in a swap each piece is checked from the tile it ends on; an in-place re-orientation or promotion that leaves it outside sends it back to its stack with a toast (one still inside stays [ASSUMED], user-confirmed 2026-10-03), and a re-orientation whose lifted summon would have no room is refused with `HAND_FULL`; on the other paths it leaves the board and returns at the next round start (§21.3).
涤火杰西卡's 机动盾牌 uses a fixed placement grid of the four orthogonally adjacent tiles, independent of her attack
range, facing, skill or module (`shared/summonPlacement.js`; her T1 in backups.json / PRTS 涤火杰西卡 天赋).
`summonRange` supplies that grid to placement, swaps, the client's highlights, the bots and range cleanup. Moving or
withdrawing her uses the ordinary summon-return/removal rules above; re-orienting either piece on its own tile keeps
the shield in place. This corrects a missing prep check; the battle's far-shield fixture remains a defensive kit test.
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7). Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7).
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready). Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
+12
View File
@@ -479,3 +479,15 @@ imports and typecheck pass. This round changes no production UI and does not rer
the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged. the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged.
- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md - **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md
(local identity recovery and handshake). (local identity recovery and handshake).
### 28.28 Jessica's shield uses four adjacent prep tiles — `shared/summonPlacement.js`, `match/player/placement.js`, `ui/gameLogic/placement.js`
- Cause: prep accepted distant and diagonal shield tiles while the kit's adjacent shield effects used the official
four-tile rule. Source: `data/backups.json` 涤火杰西卡 T1 and [PRTS 涤火杰西卡](https://prts.wiki/w/涤火杰西卡), 天赋.
- A symmetric four-tile grid feeds the existing summon-range checks: hand deployment, moves, both swap directions,
drag highlights and bot planning agree independently of her facing, skill and module. In-place reorientation keeps
a legal shield; moving her returns it, withdrawal removes it. Other summons retain their own placement rules.
Rejections and returned-shield messages use i18n. No new [ASSUMED] rule; existing in-place summon behavior is kept.
- Regression: `test/match/jessica-placement.test.js` uses real Match handlers and the client placement mirror.
It grants one operator as fixture setup; the shield is generated normally. This is model evidence, not natural UI.
+4 -1
View File
@@ -1207,5 +1207,8 @@
"请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.", "请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.",
"同意重刷": "Agree to reroll", "同意重刷": "Agree to reroll",
"拒绝重刷": "Reject reroll", "拒绝重刷": "Reject reroll",
"取消投票": "Cancel vote" "取消投票": "Cancel vote",
"只能部署在召唤者相邻四格": "Can only be deployed on the four tiles adjacent to the summoner",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round"
} }
+4 -1
View File
@@ -1213,5 +1213,8 @@
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。", "请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
"同意重刷": "再抽選に同意", "同意重刷": "再抽選に同意",
"拒绝重刷": "再抽選を拒否", "拒绝重刷": "再抽選を拒否",
"取消投票": "投票を取り消す" "取消投票": "投票を取り消す",
"只能部署在召唤者相邻四格": "召喚者に隣接する上下左右の4マスにのみ配置可能",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます"
} }
+4 -1
View File
@@ -1209,5 +1209,8 @@
"请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.", "请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.",
"同意重刷": "다시 추첨에 동의", "同意重刷": "다시 추첨에 동의",
"拒绝重刷": "다시 추첨 거부", "拒绝重刷": "다시 추첨 거부",
"取消投票": "투표 취소" "取消投票": "투표 취소",
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다"
} }
+4 -1
View File
@@ -1211,5 +1211,8 @@
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票", "请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
"同意重刷": "同意重刷", "同意重刷": "同意重刷",
"拒绝重刷": "拒絕重刷", "拒绝重刷": "拒絕重刷",
"取消投票": "取消投票" "取消投票": "取消投票",
"只能部署在召唤者相邻四格": "只能部署在召喚者相鄰四格",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還"
} }
+11 -7
View File
@@ -3,6 +3,7 @@
import { GEO } from '../../../../shared/constants.js'; import { GEO } from '../../../../shared/constants.js';
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js'; import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { meleeOnHighGround } from '../../../../shared/highGround.js'; import { meleeOnHighGround } from '../../../../shared/highGround.js';
import { summonPlacementGrid } from '../../../../shared/summonPlacement.js';
import { pieceDir, rangeTiles } from '../facing.js'; import { pieceDir, rangeTiles } from '../facing.js';
import { isObj, tileKey } from './shared.js'; import { isObj, tileKey } from './shared.js';
import { boardTileOf, fieldTile } from './camera.js'; import { boardTileOf, fieldTile } from './camera.js';
@@ -250,15 +251,18 @@ export function tileAllows(ctx, piece, row, col) {
/** /**
* Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`: * Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`:
* 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the * 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when * loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile).
* Jessica's shield uses the four-adjacent-tile grid instead, independent of facing/skill/module. Null when
* the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's * the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's
* current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange. * current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange.
* @param {ReturnType<typeof placementContext>} ctx * @param {ReturnType<typeof placementContext>} ctx
* @returns {Set<string> | null} * @returns {Set<string> | null}
*/ */
export function summonRange(ctx, piece, owner = null) { export function summonRange(ctx, piece, owner = null) {
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null; if (!isObj(piece) || piece.kind !== 'token') return null;
return ownerRangeOf(ctx, piece, owner); const grid = summonPlacementGrid(piece.id);
if (!grid && ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
return ownerRangeOf(ctx, piece, owner, grid);
} }
/** /**
@@ -273,14 +277,14 @@ export function summonExcluded(ctx, piece, owner = null) {
return ownerRangeOf(ctx, piece, owner); return ownerRangeOf(ctx, piece, owner);
} }
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */ /** The attack or fixed placement range of a summon piece's owner on the board, or null. */
function ownerRangeOf(ctx, piece, owner = null) { function ownerRangeOf(ctx, piece, owner = null, placementGrid = null) {
let at = owner; let at = owner;
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; } if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
const rec = at && ctx.getChess(at.piece.id); const rec = at && ctx.getChess(at.piece.id);
if (!rec) return null; if (!rec) return null;
let grid = null; let grid = null;
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ } try { grid = placementGrid || attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c))); return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
} }
@@ -367,7 +371,7 @@ export function canPlace(ctx, uid, target) {
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it // dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null; const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
const range = summonRange(ctx, piece, ownerSwap); const range = summonRange(ctx, piece, ownerSwap);
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围内')); if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', summonPlacementGrid(piece.id) ? t('只能部署在召唤者相邻四格') : t('只能部署在召唤者攻击范围内'));
const out = summonExcluded(ctx, piece, ownerSwap); const out = summonExcluded(ctx, piece, ownerSwap);
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外')); if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外'));
if (src.area === 'board') { if (src.area === 'board') {
+18 -10
View File
@@ -8,6 +8,7 @@ import { ERR } from '../../../shared/constants.js';
import { msg, dn } from '../../../shared/i18n.js'; import { msg, dn } from '../../../shared/i18n.js';
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js'; import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js'; import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
import { summonPlacementGrid } from '../../../shared/summonPlacement.js';
import { OK, fail } from './common.js'; import { OK, fail } from './common.js';
export class PlayerPlacement { export class PlayerPlacement {
@@ -46,7 +47,8 @@ export class PlayerPlacement {
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose * Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of * text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it * its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
* (summonExcluded). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped * (summonExcluded). Jessica's shield instead uses the fixed four-adjacent-tile grid (shared/summonPlacement.js).
* `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
* with its own owner). * with its own owner).
*/ */
_legal(piece, r, c, owner = null) { _legal(piece, r, c, owner = null) {
@@ -62,15 +64,18 @@ export class PlayerPlacement {
* The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical * The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical
* point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid * point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid
* (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board * (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board
* tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other * tile (board.js ownerRangeKeys). Jessica's shield uses its four-adjacent-tile placement grid, independent of her
* facing, skill or module. Null when the piece is not range-bound or its owner is not on the board (the other
* rules refuse such a placement). * rules refuse such a placement).
* @param {any} piece * @param {any} piece
* @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one * @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one
* @returns {Set<string> | null} * @returns {Set<string> | null}
*/ */
summonRange(piece, owner = null) { summonRange(piece, owner = null) {
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null; if (!piece || piece.kind !== 'token') return null;
return this._ownerRangeOf(piece, owner); const grid = summonPlacementGrid(piece.id);
if (!grid && this.gd.token(piece.id)?.ownerRange !== true) return null;
return this._ownerRangeOf(piece, owner, grid);
} }
/** /**
@@ -86,14 +91,14 @@ export class PlayerPlacement {
return this._ownerRangeOf(piece, owner); return this._ownerRangeOf(piece, owner);
} }
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */ /** The owner's attack or fixed placement range of a summon, or null when its owner is off the board. */
_ownerRangeOf(piece, owner = null) { _ownerRangeOf(piece, owner = null, placementGrid = null) {
let at = owner; let at = owner;
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; } if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
const chess = at && this.gd.chess(at.piece.id); const chess = at && this.gd.chess(at.piece.id);
if (!chess) return null; if (!chess) return null;
const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection) const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection)
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid; const grid = placementGrid || attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
const [r, c] = parseKey(at.key); const [r, c] = parseKey(at.key);
return ownerRangeKeys(grid, r, c, at.dir); return ownerRangeKeys(grid, r, c, at.dir);
} }
@@ -108,7 +113,7 @@ export class PlayerPlacement {
* battle. Returns the number taken off the board; a toast names them. * battle. Returns the number taken off the board; a toast names them.
*/ */
_liftOutOfRange() { _liftOutOfRange() {
const back = [], gone = [], backOut = [], goneOut = []; const back = [], gone = [], backOut = [], goneOut = [], backAdjacent = [], goneAdjacent = [];
for (const [k, p] of [...this.board]) { for (const [k, p] of [...this.board]) {
if (p.kind !== 'token') continue; if (p.kind !== 'token') continue;
const range = this.summonRange(p); const range = this.summonRange(p);
@@ -117,14 +122,17 @@ export class PlayerPlacement {
if (!inside && (!range || range.has(k))) continue; if (!inside && (!range || range.has(k))) continue;
this.board.delete(k); this.board.delete(k);
const ok = this._returnToken(p, null, { allowTemp: true }); const ok = this._returnToken(p, null, { allowTemp: true });
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id); const returned = inside ? (ok ? backOut : goneOut) : summonPlacementGrid(p.id) ? (ok ? backAdjacent : goneAdjacent) : (ok ? back : gone);
returned.push(this.gd.token(p.id)?.name || p.id);
} }
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) })); if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) })); if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
// an outside-bound summon (战术锚点) its owner's new range now covers // an outside-bound summon (战术锚点) its owner's new range now covers
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) })); if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) })); if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
return back.length + gone.length + backOut.length + goneOut.length; if (backAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,已退回整备区', { names: backAdjacent.map(dn) }));
if (goneAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,整备区已满,下回合返还', { names: goneAdjacent.map(dn) }));
return back.length + gone.length + backOut.length + goneOut.length + backAdjacent.length + goneAdjacent.length;
} }
/** /**
+9
View File
@@ -0,0 +1,9 @@
// Placement ranges that are independent of the summoner's attack range. Jessica's T1 in backups.json and PRTS
// 涤火杰西卡 / 天赋 says her shield may only stand on the four tiles next to her; op-jesca2.js already treats this as
// a prep placement rule. This symmetric grid uses the same rotation/translation helpers as owner-range summons.
const ADJACENT = Object.freeze([[0, 1], [1, 0], [0, -1], [-1, 0]].map((p) => Object.freeze(p)));
/** @returns {ReadonlyArray<ReadonlyArray<number>> | null} A fixed placement grid, or null to use the token's flags. */
export function summonPlacementGrid(tokenId) {
return tokenId === 'token_10032_jesca2_jckshd' ? ADJACENT : null;
}
+133
View File
@@ -0,0 +1,133 @@
// Jessica's shield placement uses the real Match actions and the client's drag-preview mirror. The only fixture
// grant is the selected operator; its shield is generated by normal deployment. No battle/UI is simulated here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { placementContext, canPlace, boardTargets } from '../../public/js/ui/gameLogic/placement.js';
import { planLayout } from '../../server/match/bot.js';
const SLOT = 'chess_char_5_diy1_a';
const JESCA = 'char_1034_jesca2';
const SHIELD = 'token_10032_jesca2_jckshd';
const board = (row, col) => ({ area: 'board', row, col });
const move = (h, uid, to, dir) => h.m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
const stackOf = (ps, owner) => [...ps.hand, ...ps.temp].find((p) => p?.kind === 'token' && p.ownerUid === owner.uid);
const ctx = (h, ps) => placementContext({ priv: ps.privateView(), stage: h.m.stage, editable: true,
getChess: (id) => ps.gd.chess(id), getToken: (id) => ps.gd.token(id), backups: ps.gd.raw.backups });
function prep(t, { row = 10, col = 4, skillIndex = 0, uniEquipId = null } = {}) {
const h = makeMatch({ mode: 'solo', seed: 11, seats: [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false,
connected: true, diy: { [SLOT]: { charId: JESCA, skillIndex, uniEquipId } } }] }).start();
t.after(() => h.m.dispose());
h.toPrep(1);
assert.equal(h.m.stageId, 'act2autochess_m04');
const ps = h.ps('p_0');
const owner = ps.acquireChess(SLOT, { source: 'grant' });
assert.ok(owner);
assert.deepEqual(move(h, owner.uid, board(row, col)), { ok: true });
assert.equal(stackOf(ps, owner)?.id, SHIELD);
return { h, ps, owner };
}
test('Jessica: hand shield refuses diagonal and distant tiles, with unchanged state and matching drag highlights', (t) => {
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
const shield = stackOf(ps, owner);
for (const [row, col] of [[9, 7], [10, 4], [11, 5], [12, 3]]) {
const before = structuredClone(ps.privateView());
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE', `client ${row},${col}`);
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE', `server ${row},${col}`);
assert.deepEqual(ps.privateView(), before, 'rejected placement changes no inventory or board state');
}
assert.deepEqual(boardTargets(ctx(h, ps), shield.uid).legal.sort(), [[9, 4], [9, 6], [10, 5]].sort());
h.invariants();
});
test('Jessica: the server rejects distant and diagonal placements even when the client sends them directly', (t) => {
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
const shield = stackOf(ps, owner);
for (const [row, col] of [[9, 7], [10, 4]]) {
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
assert.equal(stackOf(ps, owner), shield);
assert.ok(!ps.board.has(`${row},${col}`));
}
h.invariants();
});
test('Jessica: all four orthogonal tiles are legal regardless of her facing, skill or selected module', (t) => {
for (const [skillIndex, uniEquipId] of [[0, null], [1, 'uniequip_002_jesca2'], [2, 'uniequip_003_jesca2']]) {
const { h, ps, owner } = prep(t, { skillIndex, uniEquipId });
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
assert.deepEqual(move(h, owner.uid, board(10, 4), dir), { ok: true });
for (const [row, col] of [[9, 4], [11, 4], [10, 3], [10, 5]]) {
const shield = stackOf(ps, owner);
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).ok, true);
assert.deepEqual(move(h, shield.uid, board(row, col)), { ok: true });
assert.deepEqual(move(h, ps.board.get(`${row},${col}`).uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
}
}
h.invariants();
}
});
test('Jessica: a deployed shield cannot move or be swapped to a distant tile; adjacent moves and owner swaps work', (t) => {
const { h, ps, owner } = prep(t);
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
const shield = ps.board.get('10,5');
for (const [row, col] of [[11, 5], [9, 7]]) {
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE');
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
assert.equal(ps.board.get('10,5'), shield);
}
const other = ps.acquireChess('chess_char_1_02_a', { source: 'grant' });
assert.deepEqual(move(h, other.uid, board(9, 7)), { ok: true });
for (const [uid, target] of [[shield.uid, board(9, 7)], [other.uid, board(10, 5)]]) {
assert.equal(canPlace(ctx(h, ps), uid, target).code, 'BAD_TILE');
assert.equal(move(h, uid, target).error, 'BAD_TILE');
}
assert.equal(canPlace(ctx(h, ps), shield.uid, board(11, 4)).ok, true);
assert.deepEqual(move(h, shield.uid, board(11, 4)), { ok: true });
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 4)).ok, true);
assert.deepEqual(move(h, shield.uid, board(10, 4)), { ok: true });
assert.equal(ps.board.get('11,4'), owner);
assert.equal(ps.board.get('10,4'), shield);
h.invariants();
});
test('Jessica: owner reorientation keeps the shield; moving her returns it and withdrawal removes it', (t) => {
const { h, ps, owner } = prep(t);
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
const shield = ps.board.get('10,5');
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 5)).action, 'orient');
assert.deepEqual(move(h, shield.uid, board(10, 5), 'LEFT'), { ok: true });
assert.deepEqual(move(h, owner.uid, board(10, 4), 'UP'), { ok: true });
assert.equal(ps.board.get('10,5'), shield);
assert.deepEqual(move(h, owner.uid, board(9, 4)), { ok: true });
assert.ok(!ps.board.has('10,5'));
const returned = stackOf(ps, owner);
assert.equal(returned.count, 1);
assert.equal(canPlace(ctx(h, ps), returned.uid, board(10, 5)).code, 'BAD_TILE');
assert.equal(move(h, returned.uid, board(10, 5)).error, 'BAD_TILE');
assert.deepEqual(move(h, returned.uid, board(9, 5)), { ok: true });
assert.deepEqual(move(h, owner.uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand, ...ps.temp].some((p) => p?.ownerUid === owner.uid));
h.invariants();
});
test('Jessica: bots plan her shield on an orthogonal tile', (t) => {
const { h, ps, owner } = prep(t);
const shield = stackOf(ps, owner);
const plan = planLayout(h.m, ps, [shield], undefined, { occupied: new Set(ps.board.keys()) });
assert.ok(['9,4', '11,4', '10,3', '10,5'].includes(plan.get(shield.uid)));
h.invariants();
});
test('other summons keep their own placement rules, including a medical drone far from its owner', (t) => {
const { h, ps } = prep(t);
const silence = ps.acquireChess('chess_char_2_02_a', { source: 'grant' });
assert.deepEqual(move(h, silence.uid, board(9, 3)), { ok: true });
const drone = stackOf(ps, silence);
assert.ok(drone);
assert.equal(canPlace(ctx(h, ps), drone.uid, board(12, 8)).ok, true);
assert.deepEqual(move(h, drone.uid, board(12, 8)), { ok: true });
h.invariants();
});