feedback1 final: merge the 0.1.1 gaps (变形同构体 pairings, 调和 +1 row, bands tied to a switched-off bond, the client-result layer bound)

This commit is contained in:
sganggs committed 2026-10-03 18:45:56 +08:00
commit 3b4d44bda8
26 files changed
+1332 -65

No files matched your search

+12 -6
View File
@@ -9,7 +9,10 @@
// an item that has a `giveBondId` (research 02 §2.1).
// Special rules:
// 调和 (maniShip): while active, every CORE bond that already has ≥ 1 real member on the board gets count +1
// (one +1 total, research 02 §2.1 [ASSUMED]).
// (one +1 total, research 02 §2.1 [ASSUMED]). Such a state carries `harmony: 1` — the views send it
// (bondList: m.private bonds, m.public players[].bonds), so the client's bond popup can say where the
// extra count comes from without re-deriving the rule (DESIGN §21.26); the battle input does not
// (bondSnapshot).
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
// in name OR elite state (PRTS 修正).
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
@@ -45,10 +48,10 @@ export function pieceBonds(gd, piece) {
}
/**
* Compute every bond's state for a player.
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number }>}
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
*/
export function computeBonds(gd, ps) {
const boardChess = [];
@@ -71,7 +74,7 @@ export function computeBonds(gd, ps) {
}
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number }>} */
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>} */
const out = {};
const layersOf = (id) => {
const v = ps.layers && ps.layers[id];
@@ -101,8 +104,8 @@ export function computeBonds(gd, ps) {
const harmonyActive = harmony && raw[HARMONY_BOND] != null && tierFor(harmony, raw[HARMONY_BOND]) >= 1;
for (const id of Object.keys(raw)) {
const bond = gd.bond(id);
let count = raw[id];
if (harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1) count += 1;
const harmonyBonus = harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1 ? 1 : 0;
const count = raw[id] + harmonyBonus;
let tier = tierFor(bond, count);
if (id === DEPUTY_BOND && tier >= 1) {
// upper tiers: operators differing in name OR elite state
@@ -113,6 +116,7 @@ export function computeBonds(gd, ps) {
tier = t;
}
out[id] = { count, active: tier >= 1, tier, layers: layersOf(id) };
if (harmonyBonus) out[id].harmony = harmonyBonus;
}
return out;
}
@@ -145,6 +149,7 @@ export function activatedLayers(bonds) {
/**
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise).
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType<typeof computeBonds>} bonds
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
@@ -155,6 +160,7 @@ export function bondList(gd, bonds, { full = false } = {}) {
for (const [bondId, b] of Object.entries(bonds || {})) {
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
if (b.harmony > 0) e.harmony = b.harmony;
if (full) {
const bond = gd.bond(bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];
+24 -8
View File
@@ -59,6 +59,9 @@
// 6. resolve the temp slots (a 突变细胞 left there — it comes back after every transformation — gets a hand slot made
// for it, makeHandRoom, instead of being destroyed), keep one hand slot free (freeHandSlot: a kept bounty Art goes
// before a chess on a bot's own seat, never on a human's seat under AI 托管), then Ready.
// Strategy (botPickBand): weighted by starting LP among the offered bands; never one whose mechanic rides on a bond the
// mode switches off (gd.bandBondIds = bands.json bondIds: the bond its text names in <…> or its blackboards name — 标准's
// 潘格尼尼, 克莱门莎, 玛恩纳; DESIGN §21.26); alone, 老鲤's withheld first-round funds only rarely (× 0.02).
// 机变 (botPickCard): a bounty by its expected payout minus the expected LP lost — bountyKillChance runs the exposure
// model for that one enemy against the own board; a card the board is unlikely to beat wins only when nothing better
// is offered or it pays much more —; tactic cards by what they act on (a 盟誓 / 驰援 card on the own bonds, 升华 …);
@@ -118,19 +121,32 @@ const ECON_TRAIT_RE = /GOLD|REFRESH|COIN/;
const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]];
/**
* Band pick: weighted by starting LP (sturdier strategies are preferred). Alone, a band that withholds the first
* rounds' funds (老鲤 "资金暂存": no operator in R1–R2, every enemy leaks) is avoided — only 联防 teammates cover that.
* Band pick among the strategies the mode offers (gd.bandIds): weighted by starting LP (sturdier strategies are
* preferred). Alone, a band that withholds the first rounds' funds (老鲤 "资金暂存": no operator in R1–R2, every enemy
* leaks) is avoided — only 联防 teammates cover that. A band whose mechanic rides on a bond the mode switches off
* (gd.bandBondIds ∩ gd.modeInactiveBonds — 标准: 潘格尼尼 <拉特兰>, 克莱门莎 <阿戈尔>, 玛恩纳 <卡西米尔>) weighs 0, never taken
* (DESIGN §21.26); with every band excluded, the default band. One rng draw per call (deterministic per seed); modes
* without inactive bonds keep exactly the earlier picks.
*/
export function botPickBand(m, ps) {
const ids = m.gd.bandIds();
if (!ids.length) return m.gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(m.gd.band(id)?.desc || ''));
const pairs = ids.map((id) => [id, Math.max(1, (m.gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
const gd = m.gd;
const ids = gd.bandIds();
if (!ids.length) return gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || ''));
const offBond = (id) => gd.bandBondIds(id).some((b) => gd.modeInactiveBonds.has(b));
const pairs = ids.map((id) => [id, offBond(id) ? 0 : Math.max(1, (gd.startLp(id) - 18) ** 2) * (m.isSolo && lateFunds(id) ? 0.02 : 1)]);
let total = 0;
for (const [, w] of pairs) total += w;
let r = m.rngBots() * total;
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
return pairs[pairs.length - 1][0];
if (!(total > 0)) return gd.defaultBandId;
let last = null;
for (const [id, w] of pairs) {
if (!(w > 0)) continue;
last = id;
r -= w;
if (r < 0) return id;
}
return last;
}
/**
+50 -5
View File
@@ -32,6 +32,7 @@
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js';
import { GRANTED_CAP_OVERRIDE } from '../sim/content/garrisons/battle.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
@@ -571,6 +572,47 @@ function layerBondsOf(p, gd) {
return out;
}
/**
* What the player's IN_BATTLE layer 特质 can add to each bond in one battle on top of the flat 60 + 4·round (DESIGN
* §21.26): the traits of its units (garrisons with `bond_add_count` / `bond_add_count_multi`) and the ones their ADD_BOND
* traits hand out (`give_garrison_id`, counted for every operator of the player — "所有【X】" reaches them all), each on
* the bonds it names (`bond_by_id` ids; `bond_self` / `bond_actived_maxstack`: every bond of the player's snapshot and
* units), up to the per-battle cap the sim applies (content/garrisons/battle.js: `max_add_count_per_battle`, the handed-out
* 华法琳 trait's GRANTED_CAP_OVERRIDE). A trait the data gives no cap (初雪 / 银灰's freeze trait, 菲莱 / 百炼嘉维尔's per-skill
* 萨尔贡, 斯卡蒂's per-kill …) — or a 魔王, whose +extra on every trait gain counts toward no cap — leaves its bonds bounded
* only by the room under 999 (Infinity here): such boards legitimately gain hundreds of layers a battle.
* @returns {Map<string, number>} bondId → extra allowance (Infinity: uncapped)
*/
function layerAllowanceOf(p, gd) {
const out = new Map();
if (typeof gd.garrison !== 'function' || typeof gd.chess !== 'function' || typeof gd.bond !== 'function') return out;
const units = (Array.isArray(p.units) ? p.units : []).filter((u) => u && u.kind !== 'token' && typeof u.chessId === 'string');
const lineup = new Set(Object.keys(p.bonds && typeof p.bonds === 'object' ? p.bonds : {}));
for (const u of units) for (const b of gd.chess(u.chessId)?.bonds || []) lineup.add(b);
let extra = false;
const credit = (g, times, handedOut) => {
const bb = g.bb || {};
if (Number.isFinite(bb.extra_cnt)) { extra = true; return; }
if (!Number.isFinite(bb.bond_add_count) && !Number.isFinite(bb.bond_add_count_multi)) return;
const s = g.bbStr || {};
const bonds = s.bond_type === 'bond_by_id' ? String(s.bond_id ?? '').split(',').map((x) => x.trim()).filter((x) => gd.bond(x)) : [...lineup];
const override = handedOut ? GRANTED_CAP_OVERRIDE[g.garrisonId] : undefined;
const cap = override ?? (Number(bb.max_add_count_per_battle) > 0 ? Number(bb.max_add_count_per_battle) : Infinity);
for (const b of bonds) out.set(b, (out.get(b) || 0) + cap * times);
};
for (const u of units) {
for (const gid of gd.chess(u.chessId)?.garrisonIds || []) {
const g = gd.garrison(gid);
if (!g || g.eventType !== 'IN_BATTLE') continue;
if (g.effectKey !== 'ADD_BOND') { credit(g, 1, false); continue; }
const given = gd.garrison(g.bbStr?.give_garrison_id);
if (given && given.eventType === 'IN_BATTLE') credit(given, units.length, true);
}
}
if (extra) for (const b of out.keys()) out.set(b, Infinity);
return out;
}
/**
* Bounds of a battle derived from its spec: spawn counts per enemy key, keys content may add, bounty coins, the
* player ids and each player's unit uids.
@@ -640,7 +682,7 @@ export function specBounds(spec, gd = null) {
const v = Number(b && b.layers);
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
}
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers });
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
}
const round = Number(spec && spec.round) || 0;
return {
@@ -686,8 +728,9 @@ const sameMods = (a, b) => {
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
* gains ≤ 60 + 4·round and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only on bonds the
* player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* gains ≤ 60 + 4·round + what the player's layer 特质 can add to that bond (layerAllowanceOf: their per-battle caps, no
* flat bound when one is uncapped) and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only
* on bonds the player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* perfect consistent with the counted leaks; unit states only for the player's own units, within range.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
@@ -770,8 +813,10 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
// ≤ 60 + 4·round, and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
if (!finiteIn(n, 0, Math.min(B.layerCap, layerGainRoom(own.startLayers.get(bondId) || 0, Infinity)))) return bad('layer bound');
// ≤ 60 + 4·round + what the player's layer 特质 can add to the bond (layerAllowanceOf; uncapped ones: no flat
// bound), and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
const bound = Math.min(B.layerCap + (own.layerAllow.get(bondId) || 0), layerGainRoom(own.startLayers.get(bondId) || 0, Infinity));
if (!finiteIn(n, 0, bound)) return bad('layer bound');
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
+20
View File
@@ -90,6 +90,8 @@ export class GameData {
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
this.bondIds = Object.keys(this._bonds).sort((a, b) => (numOr(this._bonds[a].identifier, 99) - numOr(this._bonds[b].identifier, 99)) || (a < b ? -1 : 1));
this.modeInactiveBonds = new Set(Array.isArray(this.mode.inactiveBondIds) ? this.mode.inactiveBondIds : []);
/** bandBondIds memo */
this._bandBonds = new Map();
this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []);
/** data/tuning.json (titles only, see the header) */
this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {};
@@ -458,6 +460,24 @@ export class GameData {
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
}
/**
* The bonds a strategy's mechanic is built around (DESIGN §21.26): bands.json `bondIds`, written at build time by
* shared/bandBonds.js from the band's own text and blackboards (潘格尼尼 → 拉特兰, 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔 …) —
* the field the strategy draft's 本局禁用 mark reads too. Known bond ids in data order; [] for an unknown band, one tied to
* no bond (华法琳, 阿米娅 …) or data without the field. The bot never picks a strategy tied to a bond the mode switches
* off (bot.js botPickBand).
* @param {string} bandId
* @returns {string[]}
*/
bandBondIds(bandId) {
if (this._bandBonds.has(bandId)) return this._bandBonds.get(bandId);
const listed = this.band(bandId)?.bondIds;
const set = new Set(Array.isArray(listed) ? listed : []);
const out = Object.freeze(this.bondIds.filter((id) => set.has(id)));
this._bandBonds.set(bandId, out);
return out;
}
/**
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its manually
* deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)