diff --git a/docs/META.md b/docs/META.md index 57dc8d58..be42a207 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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 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 - (`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 `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 diff --git a/docs/design/match.md b/docs/design/match.md index 06612e05..0cd11dae 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -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). +涤火杰西卡'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). 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). diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 5fd8b3f9..c81d0d72 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -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. - **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md (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. diff --git a/public/i18n/en.json b/public/i18n/en.json index f2725494..2049a68c 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1207,5 +1207,8 @@ "请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.", "同意重刷": "Agree to 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" } diff --git a/public/i18n/ja.json b/public/i18n/ja.json index c26fd069..d87d0fa9 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -1213,5 +1213,8 @@ "请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。", "同意重刷": "再抽選に同意", "拒绝重刷": "再抽選を拒否", - "取消投票": "投票を取り消す" + "取消投票": "投票を取り消す", + "只能部署在召唤者相邻四格": "召喚者に隣接する上下左右の4マスにのみ配置可能", + "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました", + "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます" } diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 77a5cc9a..9b800513 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -1209,5 +1209,8 @@ "请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.", "同意重刷": "다시 추첨에 동의", "拒绝重刷": "다시 추첨 거부", - "取消投票": "투표 취소" + "取消投票": "투표 취소", + "只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다", + "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다", + "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다" } diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index d4687d38..bc0a5a7c 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -1211,5 +1211,8 @@ "请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票", "同意重刷": "同意重刷", "拒绝重刷": "拒絕重刷", - "取消投票": "取消投票" + "取消投票": "取消投票", + "只能部署在召唤者相邻四格": "只能部署在召喚者相鄰四格", + "{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區", + "{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還" } diff --git a/public/js/ui/gameLogic/placement.js b/public/js/ui/gameLogic/placement.js index cd0ad0fa..342f78a7 100644 --- a/public/js/ui/gameLogic/placement.js +++ b/public/js/ui/gameLogic/placement.js @@ -3,6 +3,7 @@ import { GEO } from '../../../../shared/constants.js'; import { attackRangeGrid } from '../../../../shared/loadoutRecord.js'; import { meleeOnHighGround } from '../../../../shared/highGround.js'; +import { summonPlacementGrid } from '../../../../shared/summonPlacement.js'; import { pieceDir, rangeTiles } from '../facing.js'; import { isObj, tileKey } from './shared.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`: * 伺夜'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 * current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange. * @param {ReturnType} ctx * @returns {Set | null} */ export function summonRange(ctx, piece, owner = null) { - if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null; - return ownerRangeOf(ctx, piece, owner); + if (!isObj(piece) || piece.kind !== 'token') return null; + 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); } -/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */ -function ownerRangeOf(ctx, piece, owner = null) { +/** The attack or fixed placement range of a summon piece's owner on the board, or null. */ +function ownerRangeOf(ctx, piece, owner = null, placementGrid = null) { 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; } const rec = at && ctx.getChess(at.piece.id); if (!rec) return 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))); } @@ -367,7 +371,7 @@ export function canPlace(ctx, uid, target) { // 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 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); if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外')); if (src.area === 'board') { diff --git a/server/match/player/placement.js b/server/match/player/placement.js index 011e05f5..93ed9992 100644 --- a/server/match/player/placement.js +++ b/server/match/player/placement.js @@ -8,6 +8,7 @@ import { ERR } from '../../../shared/constants.js'; import { msg, dn } from '../../../shared/i18n.js'; import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js'; import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js'; +import { summonPlacementGrid } from '../../../shared/summonPlacement.js'; import { OK, fail } from './common.js'; 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 * 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 - * (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). */ _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 * 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 - * 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). * @param {any} piece * @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one * @returns {Set | null} */ summonRange(piece, owner = null) { - if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null; - return this._ownerRangeOf(piece, owner); + if (!piece || piece.kind !== 'token') return null; + 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); } - /** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */ - _ownerRangeOf(piece, owner = null) { + /** The owner's attack or fixed placement range of a summon, or null when its owner is off the board. */ + _ownerRangeOf(piece, owner = null, placementGrid = null) { 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; } const chess = at && this.gd.chess(at.piece.id); if (!chess) return null; 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); 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. */ _liftOutOfRange() { - const back = [], gone = [], backOut = [], goneOut = []; + const back = [], gone = [], backOut = [], goneOut = [], backAdjacent = [], goneAdjacent = []; for (const [k, p] of [...this.board]) { if (p.kind !== 'token') continue; const range = this.summonRange(p); @@ -117,14 +122,17 @@ export class PlayerPlacement { if (!inside && (!range || range.has(k))) continue; this.board.delete(k); 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 (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) })); // 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 (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; } /** diff --git a/shared/summonPlacement.js b/shared/summonPlacement.js new file mode 100644 index 00000000..57143c1a --- /dev/null +++ b/shared/summonPlacement.js @@ -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> | 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; +} diff --git a/test/match/jessica-placement.test.js b/test/match/jessica-placement.test.js new file mode 100644 index 00000000..d2517c6c --- /dev/null +++ b/test/match/jessica-placement.test.js @@ -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(); +});