feedback3: merge WJ — 高台 only for elite 歌蕾蒂娅 with HOK-Y, live prep watch, silenced 折射, grant toasts, emote timeout glyph

This commit is contained in:
sganggs committed 2026-10-04 21:26:46 +08:00
commit c9dd2a6392
41 files changed
+700 -216

No files matched your search

+37 -7
View File
@@ -781,8 +781,8 @@ export class Match {
const list = [...this._privDirty];
this._privDirty.clear();
for (const ps of list) {
if (ps.isBot || ps.left || !ps.connected) continue;
this._sendPrivate(ps, false);
if (!(ps.isBot || ps.left || !ps.connected)) this._sendPrivate(ps, false);
this._notifyPrepScouts(ps);
}
}
if (this._pubDirty || forcePublic) this._maybeSendPublic(forcePublic);
@@ -970,6 +970,35 @@ export class Match {
return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, prep: true, nextEnemies };
}
/** Board signature of a prep scout view (units only: a shop or funds change is not a board change). */
_prepScoutSig(ps) {
const parts = [];
for (const { r, c, piece } of boardOrder(ps.board)) {
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
}
return parts.join(';');
}
/**
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed.
* A live battle field owns the `n:<pid>` id, so this stays quiet once fields exist.
*/
_notifyPrepScouts(ps, { to = null } = {}) {
if (!ps || !ps.alive || this.fields.length) return;
const fid = `n:${ps.playerId}`;
const watchers = this.watchersOf(fid);
if (!watchers.length) return;
const sig = this._prepScoutSig(ps);
const changed = sig !== ps._prepScoutSig;
ps._prepScoutSig = sig;
const dest = changed ? watchers : (to ? [to] : []);
if (!dest.length) return;
const meta = this.prepFieldMeta(ps);
for (const pid of dest) this.sendTo(pid, meta);
}
_sendField(playerId, fieldId) {
const f = this.fields.find((x) => x.fieldId === fieldId);
if (f) {
@@ -1053,14 +1082,15 @@ export class Match {
return OK;
}
if (fieldId.startsWith('n:')) {
// prep scouting — a one-shot board view — only while no battle field is up: during 各自行动 / 联防 / 最终攻势 /
// 隐秘核心 an 'n:<pid>' id must name a live field (else the boss-group rule above could be bypassed, and the
// viewer would stop receiving its own field's snapshots)
// prep scouting, only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / 隐秘核心 an 'n:<pid>' id
// must name a live field (else the boss-group rule above could be bypassed, and the viewer would stop receiving
// its own field's snapshots). The scout stays in `watchers` so a later board change pushes prepFieldMeta again
// (GitHub #87); combat start clears the map and reassigns live fields, spectators included.
if (this.fields.length) return fail(ERR.BAD_TARGET, 'no such field');
const target = this.players.get(fieldId.slice(2));
if (!target || !target.alive) return fail(ERR.BAD_TARGET);
this.watchers.delete(ps.playerId);
this.sendTo(ps.playerId, this.prepFieldMeta(target));
this.watchers.set(ps.playerId, fieldId);
this._notifyPrepScouts(target, { to: ps.playerId });
return OK;
}
return fail(ERR.BAD_TARGET);
+3 -2
View File
@@ -72,7 +72,7 @@
import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
import { FIELD, tileKey, parseKey, inField, canPlace, placeClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains, offBondCounts } from './bondsMeta.js';
@@ -1009,7 +1009,8 @@ export class PlayerState {
_placementOf(piece) {
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
return positionClass(rec);
// elite 歌蕾蒂娅 + HOK-Y may use a 高台; the module is this player's loadout (owner's decision 2026-10-04)
return placeClass(this, rec);
}
/**
+2 -2
View File
@@ -42,7 +42,7 @@
import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js';
import { mergeTile, pieceDir, canPlace, positionClass } from './board.js';
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js';
@@ -243,7 +243,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
const pos = positionClass(gd.chess(elite.id));
const pos = placeClass(ps, gd.chess(elite.id));
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
if (want) {
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
+31 -10
View File
@@ -4,8 +4,8 @@
// stage legend (data/stages.json `tiles[glyph]`: height + buildable) and the stage devices:
// 'melee' — LOW tile with buildable ALL/MELEE, or a 深水区 under an active 特制水上平台 (waterPlatform, "在水上建立可以
// 部署任意单位的平台"): melee AND ranged chess may stand here ("所有行动内远程干员可部署在近战位").
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and the
// 钩索师 / 推击手, placement 'all' below).
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and elite
// 歌蕾蒂娅 carrying HOK-Y, placeClass below).
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds, the 深水区 — the legend's
// `buildable` is the effective type: tile_deepsea refuses deployment, PRTS 深水区 地形信息
// "拒绝部署(待补充)", although its level buildableType is ALL; player report #3 after 0.1.0, 战场#08's
@@ -23,9 +23,10 @@
// (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).
//
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except a MELEE operator whose branch
// trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all' ⇒ any deployable tile, `positionClass`;
// GitHub issue #32 item 4, DESIGN §22.6 [ASSUMED]: the branch trait read as no 部署效果, which the mode would switch off).
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except elite 歌蕾蒂娅 carrying module
// HOK-Y 淡金坠饰 (uniequip_003_glady: `placeClass` / shared/highGround.js ⇒ any deployable tile). The branch trait
// 「可以放置于远程位」 is not read (owner's decision 2026-10-04, reversing DESIGN §22.6): 崖心, 见行者, a normal record,
// any other module and no module are ground-only. The equipped module is the player's loadout at place time.
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
// 缪尔赛思's 流形; PRTS 狼群 特性) also needs a tile of its owner's attack range: `ownerRangeKeys` = the owner's range
@@ -35,6 +36,7 @@
// `parseDir` validates an intent's optional direction.
import { GEO } from '../../shared/constants.js';
import { meleeOnHighGround } from '../../shared/highGround.js';
import { DEFAULT_DIR, isDir, rotateOffset } from '../sim/dir.js';
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
@@ -133,12 +135,31 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
}
/**
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all' — its position class, widened to 'all' for a
* chess whose branch trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all', DESIGN §22.6), which may
* stand on the ranged (高台) tiles too.
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. Widened to 'all' only for elite 歌蕾蒂娅
* carrying HOK-Y (`moduleId` = uniequip_003_glady, shared/highGround.js): she may stand on the ranged (高台) tiles.
* Any other module, no module, and every other MELEE record stay 'melee'. `chess.json` has no `placement` field.
* @param {object|null} rec
* @param {string|null} [moduleId] equipped module; ignored for tokens and non-golden records
*/
export function positionClass(rec) {
return rec && rec.placement === 'all' ? 'all' : basePositionClass(rec);
export function positionClass(rec, moduleId = null) {
return meleeOnHighGround(rec, moduleId) ? 'all' : basePositionClass(rec);
}
/**
* Placement class of a record under a player's loadout (the module `ps.loadoutFor` resolves, defaults included).
* Tokens and records without a chess id ignore the loadout.
* @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null }|null} ps
*/
export function placeClass(ps, rec) {
if (!rec || !rec.chessId || !ps || typeof ps.loadoutFor !== 'function') return positionClass(rec);
let moduleId = null;
try {
const lo = ps.loadoutFor(rec);
moduleId = lo && typeof lo.moduleId === 'string' ? lo.moduleId : null;
} catch {
moduleId = null;
}
return positionClass(rec, moduleId);
}
/**
+22 -12
View File
@@ -33,9 +33,9 @@
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
// covering units against the round's DEF / RES, 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 of the server's deploy map (no 深水区; a 钩索师 / 推击手, which may also stand on
// a 高台 — placement 'all', DESIGN §22.6 —, is planned on the ground tiles like any blocker, as in 0.1.1
// [ASSUMED: the lineup counts it as a blocker]): each unit's range grid — the one it is
// (legal tile, direction) pair of the server's deploy map (no 深水区). A MELEE operator is planned on the ground
// tiles, where it blocks — except elite 歌蕾蒂娅 carrying HOK-Y, who may also take a 高台 (placeClass; owner's
// decision 2026-10-04). She stays a blocker in the lineup (basePositionClass). Each unit's range grid — the one it is
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — 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
@@ -77,7 +77,7 @@
import { GEO } from '../../shared/constants.js';
import { deriveSeed } from '../sim/rng.js';
import { ASPD_MIN } from '../sim/constants.js';
import { freeSlot, countFree, legalTiles, canPlace, positionClass, basePositionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { freeSlot, countFree, legalTiles, canPlace, positionClass, placeClass, basePositionClass, 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';
@@ -276,7 +276,7 @@ function tacticScore(m, ps, card) {
const chessRec = (m, id) => m.gd.chess(id);
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
// the record's own position: a 钩索师 / 推击手 (placement 'all', DESIGN §22.6) is a MELEE blocker like any other
// the record's own position: a 钩索师 / 推击手 is a MELEE blocker like any other (a 高台 does not block)
const isBlocker = (c) => !!c && basePositionClass(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);
@@ -949,8 +949,13 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
let bestV = -Infinity;
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
// the record's own position class: a 钩索师 / 推击手 (placement 'all') stays on the ground tiles, where it blocks
for (const [r, c] of legalTiles(map, basePositionClass(r0))) {
// ground tiles for a MELEE blocker. placeClass 'all' is only elite 歌蕾蒂娅 + HOK-Y: she may stand on a 高台,
// and when one of those tiles covers the enemy road she is planned there (owner 2026-10-04: the bot uses the 高台).
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, m.gd.chess(p.id) || r0);
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null;
let bestHighV = -Infinity;
for (const [r, c] of legalTiles(map, cls)) {
const k = tileKey(r, c);
if (taken.has(k) || (within && !within.has(k))) continue;
const noise = m.rngBots() * 1e-6;
@@ -965,13 +970,18 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const v = layout.value() + noise;
layout.units.pop();
if (v > bestV) { bestV = v; best = [k, r, c, dir]; }
if (preferHigh && map.get(k) === 'ranged' && [...u.cover].some((ck) => model.ground.has(ck)) && v > bestHighV) {
bestHighV = v;
bestHigh = [k, r, c, dir];
}
}
}
if (!best) continue;
taken.add(best[0]);
layout.units.push(unitOf(r0, best[0], best[1], best[2], best[3], model));
out.set(p.uid, best[0]);
out.dirs.set(p.uid, best[3]);
const pick = bestHigh || best;
if (!pick) continue;
taken.add(pick[0]);
layout.units.push(unitOf(r0, pick[0], pick[1], pick[2], pick[3], model));
out.set(p.uid, pick[0]);
out.dirs.set(p.uid, pick[3]);
yield;
}
return out;
+15
View File
@@ -412,6 +412,11 @@ export function offerLabel(gd, source) {
return null;
}
/** Who a grantChess toast names: the same speaker as an offer label, or the choice card's name. */
function grantSpeaker(gd, source) {
return offerLabel(gd, source) || (source && source.kind === 'choice' && source.card && source.card.name) || '';
}
/**
* @param {import('./Match.js').Match} m
* @param {import('./PlayerState.js').PlayerState} ps
@@ -513,6 +518,16 @@ export function makeCtx(m, ps, source, hook, ev = null) {
// "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy
if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null;
const p = ps.acquireChess(id, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp, fromPool: opts.fromPool !== false });
// 「歌蕾蒂娅:获得斯卡蒂」 — every silent grantChess (a 特质, 余 SERVER_MOST_BOND, a band, an item, a choice).
// opts.toast === false skips it. A caller that already says the same thing should pass that.
if (p && opts.toast !== false) {
const got = gd.chess(p.id);
const name = got && got.name;
if (name) {
const who = grantSpeaker(gd, source);
m.toast(ps, 'info', who ? `${who}:获得${name}` : `获得${name}`);
}
}
return p ? view(p) : null;
},
grantItem: (itemId, opts = {}) => {
+3 -2
View File
@@ -17,7 +17,7 @@
// match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { FIELD, canPlace, positionClass, parseKey } from './board.js';
import { FIELD, canPlace, placeClass, positionClass, parseKey } from './board.js';
import { computeBonds } from './bondsMeta.js';
const PHASES = new Set(Object.values(PHASE));
@@ -111,7 +111,8 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
if (rec && !canPlace(dmap, positionClass(rec), r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
if (rec && !canPlace(dmap, cls, r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
const range = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);