feedback5 WK: 自选编队 on the server — room.diy { picks } (lenient: illegal picks dropped, only malformed input is BAD_MSG; stored per session / seat; a match takes the picks its seat had at the start, a change during a match is for the next one; welcome lists diyKitted), PlayerState.diy with the player's data view (a slotted DIY slot is its operator for every rule: bonds from its factions, no 特质, the pick's skill and module, the slot's tier / price / merge), a per-player stock per slotted piece (8 at tier 5, 5 at tier 6 [ASSUMED]; never in the shared pool; none when every bond of it is banned), DIY pieces in that player's shop rolls and reward offers once the 调度中心 reaches the slot's level, battleInput diy, m.private.diy / diyBanned, prep scouting and results name the operator, 信标 never sends a 自选 piece to a teammate [ASSUMED]

The owner's decisions of 2026-10-05 (picks, slots, shop per player, bans); research 0.2.0 §2. Golden unchanged (no scenario slots a DIY pick).
Tests: test/match/diy-shop.test.js (9), test/lobby-diy.test.js (4).
This commit is contained in:
sganggs committed 2026-10-05 23:35:01 +08:00
1 parent 742f478526
commit 4bee542e61
26 files changed
+1026 -105

No files matched your search

+37 -34
View File
@@ -133,7 +133,7 @@ const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]];
* without inactive bonds keep exactly the earlier picks.
*/
export function botPickBand(m, ps) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const ids = gd.bandIds();
if (!ids.length) return gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || ''));
@@ -185,7 +185,7 @@ export function botPickCard(m, ps, cards, available) {
* first blocker's hold on the ground. 0.5 without data.
*/
export function bountyKillChance(m, ps, card) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const e = card && gd.enemy(card.enemyKey);
if (!e || !e.stats) return 0.5;
const model = fieldModel(m, ps);
@@ -274,7 +274,8 @@ function tacticScore(m, ps, card) {
// ---------------------------------------------------------------------------------------------------
// evaluation helpers
const chessRec = (m, id) => m.gd.chess(id);
// a player's record of a chess id (its data view: a human's slotted 自选 slots are its operators, 0.2.0 player/diy.js)
const chessRec = (m, id, ps = null) => (ps?.gd || m.gd).chess(id);
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
// 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';
@@ -291,7 +292,7 @@ function ownedBonds(m, ps, exclude = null) {
const base = m.gd.baseIdOf(p.id);
if (seen.has(base)) continue;
seen.add(base);
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
for (const b of (c && c.bonds) || []) counts.set(b, (counts.get(b) || 0) + 1);
}
return { counts, bases: seen };
@@ -393,7 +394,7 @@ function keeperBases(m, ps, plan) {
const out = new Set();
for (const p of ps.board.values()) if (p.kind === 'chess') out.add(m.gd.baseIdOf(p.id));
for (const p of ps.allChess()) {
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
if (!c) continue;
const bonds = c.bonds || [];
if ((plan.focus && bonds.includes(plan.focus)) || (plan.second && bonds.includes(plan.second)) || c.tier >= ps.shop.level - 1) out.add(m.gd.baseIdOf(p.id));
@@ -405,7 +406,7 @@ function keeperBases(m, ps, plan) {
function roles(m, ps) {
const r = { blockers: 0, antiAir: 0, healers: 0, total: 0 };
for (const p of ps.allChess()) {
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
if (!c) continue;
r.total++;
if (isBlocker(c)) r.blockers++;
@@ -473,7 +474,7 @@ function traitsOf(m, c) {
/** Stand-alone value of an owned piece (no bond context): power, items, role fit, layer engines. */
function unitBase(m, piece, ctx) {
const c = chessRec(m, piece.id);
const c = chessRec(m, piece.id, ctx.ps);
if (!c) return 0;
let v = power(c) + (piece.items ? piece.items.length * 5 : 0);
if (m.round <= 11) v += traitsOf(m, c).recurring * 4;
@@ -485,7 +486,7 @@ function unitBase(m, piece, ctx) {
/** Value of an owned piece for bench / sell decisions (its bonds counted against the other owned chess). */
function pieceValue(m, ps, piece, ctx) {
const c = chessRec(m, piece.id);
const c = chessRec(m, piece.id, ps);
if (!c) return 0;
return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus, ctx.second) * 0.8;
}
@@ -495,7 +496,7 @@ function pieceValue(m, ps, piece, ctx) {
* members still count for BOARD_AND_DECK bonds) + composition (blockers, anti-air when the wave flies, ≤ 2 healers).
*/
function lineupScore(m, ps, set, ctx) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const board = new Map();
set.forEach((p, i) => board.set(`x${i}`, p));
const inSet = new Set(set.map((p) => p.uid));
@@ -518,7 +519,7 @@ function lineupScore(m, ps, set, ctx) {
let blockers = 0;
let air = 0;
let healers = 0;
for (const p of set) { const c = chessRec(m, p.id); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; }
for (const p of set) { const c = chessRec(m, p.id, ps); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; }
v -= Math.max(0, Math.min(2, ctx.roles.blockers) - blockers) * 15;
if (ctx.fly > 0 && ctx.roles.antiAir > 0 && air === 0) v -= 12;
v -= Math.max(0, healers - 2) * 10;
@@ -562,8 +563,8 @@ function chooseLineup(m, ps, ctx) {
/** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */
function buyScore(m, ps, id, ctx) {
const gd = m.gd;
const c = chessRec(m, id);
const gd = ps?.gd || m.gd;
const c = chessRec(m, id, ps);
if (!c) return 0;
let s = power(c) * 0.6;
const tr = traitsOf(m, c);
@@ -639,7 +640,7 @@ export function fieldModel(m, ps = null) {
const wave = m.wave || (boss && boss.wave);
const cacheKey = `${m.round}|${m.stageId}|${wave ? wave.templateId : 'boss'}|${boss ? boss.side : ''}`;
if (m._botPath && m._botPath.key === cacheKey) return m._botPath;
const gd = m.gd;
const gd = ps?.gd || m.gd;
const st = m.stage;
const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {};
const routesOut = [];
@@ -835,7 +836,8 @@ export function rangeRec(ps, rec) {
export function effDps(model, rec) {
if (!rec) return 0;
if (!model || !Array.isArray(model.armor) || !model.armor.length) return dpsOf(rec);
const id = rec.chessId || rec.tokenId || rec.id || rec.name;
// (a 自选 record — one DIY slot id, each player's own operator — is cached per operator and skill)
const id = rec.diyFor ? `${rec.chessId}@${rec.charId}#${rec.skill?.index ?? ''}` : rec.chessId || rec.tokenId || rec.id || rec.name;
const cache = model.dps instanceof Map ? model.dps : null;
if (cache && cache.has(id)) return cache.get(id);
let v = 0;
@@ -938,7 +940,8 @@ export function planLayout(m, ps, pieces, params = LAYOUT_PARAMS, opts = {}) {
export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null } = {}) {
const model = fieldModel(m, ps);
const map = ps.deployMap();
const rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : rangeRec(ps, m.gd.chess(p.id))));
const pgd = ps.gd || m.gd;
const rec = recOf || ((p) => (p.kind === 'token' ? pgd.token(p.id) : rangeRec(ps, pgd.chess(p.id))));
const layout = new Layout(model, params);
const rank = (p) => { const r = rec(p); return isBlocker(r) ? 0 : isHealer(r) ? 2 : 1; };
const order = pieces.slice().sort((a, b) => rank(a) - rank(b) || dpsOf(rec(b)) - dpsOf(rec(a)) || a.uid - b.uid);
@@ -953,7 +956,7 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
// 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 cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null;
let bestHighV = -Infinity;
@@ -1161,7 +1164,7 @@ function context(m, ps) {
const copies = copyCounts(m, ps);
let pairs = 0;
for (const [b, k] of copies) if (k + 1 >= mergeNeed(m, b)) pairs++;
return { owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model };
return { ps, owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model };
}
/** Normal copies owned per base (board, hand, temp). */
@@ -1254,7 +1257,7 @@ function sellJunk(m, ps) {
const keep = [];
const junk = [];
for (const p of bench) {
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
if (!c) continue;
const base = m.gd.baseIdOf(p.id);
const copies = c.isGolden ? 0 : ctx.copies.get(base) || 0;
@@ -1271,7 +1274,7 @@ function sellJunk(m, ps) {
/** Take the queued pick-one offers (merge rewards, special refreshes — free; they expire at prep end). */
function takeOffers(m, ps) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 4 && ps.offers.length; guard++) {
const offer = ps.offers[0];
const ctx = context(m, ps);
@@ -1378,7 +1381,7 @@ function maybeFreeze(m, ps) {
}
function levelUp(m, ps, { spare = false } = {}) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 6; guard++) {
if (ps.shop.level >= gd.maxShopLevel) return;
const price = Math.max(0, ps.shop.upgradePrice);
@@ -1413,7 +1416,7 @@ function fundsReserve(m, ps) {
/** Buying / rerolling toward the lineup; a step generator (yields after each purchase or reroll). */
function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
let refreshes = 0;
const minPrice = 2;
const reserve = fundsReserve(m, ps);
@@ -1464,7 +1467,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
const gain = lineupGain(m, ps, s.id, ctx, cur);
if (gain <= 2) return;
sc += Math.min(15, gain * 0.5);
} else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id)?.tier ?? 6) <= 3 && countFree(ps.hand) >= 3))) {
} else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id, ps)?.tier ?? 6) <= 3 && countFree(ps.hand) >= 3))) {
// a second copy of a non-keeper with no bench room for another pair is clutter, sold later at a loss
return;
}
@@ -1607,7 +1610,7 @@ const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, '
* first — they take the hits —, then DPS), pointing at it; null when none is free.
*/
function supportSpot(m, ps, p) {
const rec = m.gd.token(p.id);
const rec = (ps.gd || m.gd).token(p.id);
if (!rec) return null;
const map = ps.deployMap();
const pos = positionClass(rec);
@@ -1618,7 +1621,7 @@ function supportSpot(m, ps, p) {
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
faced.add(tileKey(r + dr, c + dc));
}
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, (ps.gd || m.gd).chess(q.id)]).filter(([, c]) => c)
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [k] of ops) {
const [r, c] = parseKey(k);
@@ -1676,7 +1679,7 @@ const isMutationCell = (gd, itemId) => itemEffect(gd.item(itemId)) === 'char_che
* progress would be lost), nobody already carrying a cell. null: the cell waits in the hand.
*/
export function cellTarget(m, ps, ctx = context(m, ps)) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
let best = null;
let bestV = Infinity;
for (const p of ps.allChess()) {
@@ -1707,7 +1710,7 @@ function lastPerfect(m, ps) {
* other equipment: the strongest deployed damage dealers with a free slot (SURVIVAL items blockers first).
*/
export function itemTarget(m, ps, item, ctx = context(m, ps)) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const rec = gd.item(item.id);
if (!rec) return null;
const key = itemEffect(rec);
@@ -1717,19 +1720,19 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) {
const owned = ps.allChess();
const normal = (p) => !gd.isGolden(p.id);
const byVal = (list, dir = -1) => list.slice().sort((a, b) => dir * (val(a) - val(b)) || a.uid - b.uid);
const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; };
const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id, ps)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; };
switch (key) {
case 'use_equip_recruit_new_char_and_give_char_to_player_most_bond': {
// a bench single of the highest tier (a pick of two of its tier), else the weakest deployed normal operator
const counts = copyCounts(m, ps);
const bench = owned.filter((p) => normal(p) && ps.find(p.uid)?.area !== 'board' && (counts.get(gd.baseIdOf(p.id)) || 0) < 2)
.sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || val(a) - val(b) || a.uid - b.uid);
.sort((a, b) => (chessRec(m, b.id, ps)?.tier || 0) - (chessRec(m, a.id, ps)?.tier || 0) || val(a) - val(b) || a.uid - b.uid);
return bench[0] || byVal(deployed.filter(normal), 1)[0] || null;
}
case 'use_equip_reward_char_chess': {
const counts = copyCounts(m, ps);
const pair = owned.filter((p) => normal(p) && (counts.get(gd.baseIdOf(p.id)) || 0) + 1 >= mergeNeed(m, gd.baseIdOf(p.id)))
.sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || a.uid - b.uid);
.sort((a, b) => (chessRec(m, b.id, ps)?.tier || 0) - (chessRec(m, a.id, ps)?.tier || 0) || a.uid - b.uid);
return pair[0] || owned.slice().sort((a, b) => rel(b) - rel(a) || val(b) - val(a) || a.uid - b.uid)[0] || null;
}
// 博士投影: the normal one promotes at the next round start, the golden one (缪尔赛思's R1 item, item merges) at once;
@@ -1751,12 +1754,12 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) {
if (consume) return list[0] || owned[0] || null;
const free = (p) => (p.items || []).length < gd.equipPerChess;
if (rec.requiresBondId) {
const member = list.find((p) => free(p) && (chessRec(m, p.id)?.bonds || []).includes(rec.requiresBondId));
const member = list.find((p) => free(p) && (chessRec(m, p.id, ps)?.bonds || []).includes(rec.requiresBondId));
if (member) return member;
}
// damage dealers carry equipment first (healers / non-attackers last); survival items on blockers first
const dealer = (p) => { const c = chessRec(m, p.id); return c && !isHealer(c) && c.attackKind !== 'none'; };
const blocker = (p) => isBlocker(chessRec(m, p.id));
const dealer = (p) => { const c = chessRec(m, p.id, ps); return c && !isHealer(c) && c.attackKind !== 'none'; };
const blocker = (p) => isBlocker(chessRec(m, p.id, ps));
const order = rec.category === 'SURVIVAL'
? [...list.filter(blocker), ...list.filter((p) => !blocker(p) && dealer(p)), ...list.filter((p) => !blocker(p) && !dealer(p))]
: [...list.filter(dealer), ...list.filter((p) => !dealer(p))];
@@ -1794,7 +1797,7 @@ function useArt(m, ps, item, ctx) {
}
function equipItems(m, ps) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const tried = new Set();
for (let guard = 0; guard < 12; guard++) {
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
@@ -1820,7 +1823,7 @@ function makeHandRoom(m, ps, piece = null) {
const room = () => freeSlot(ps.hand) >= 0 || (piece != null && !ps.temp.includes(piece));
if (room()) return true;
if (sellWeakestHand(m, ps, { handOnly: true }) && room()) return true;
const gd = m.gd;
const gd = ps?.gd || m.gd;
const junk = ps.isBot && !ps.autoplay ? ps.hand.filter((p) => p && p.kind === 'item' && !isMutationCell(gd, p.id))
.sort((a, b) => ((gd.item(a.id) || {}).price || 0) - ((gd.item(b.id) || {}).price || 0) || a.uid - b.uid)[0] : null;
if (junk && tryDo(() => ps.destroy(junk.uid)) && room()) return true;