feedback1 integrate: ticker lines by broadcast priority; AI 托管 keeps a human's items out of temp

- the strip queues by the official broadcast priority (BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > ...),
  match-flow notices at 25, never cutting the line on screen short
- on a human seat under AI 托管 an item left in temp with a full hand gets a hand slot (a bench operator sold)
This commit is contained in:
sganggs committed 2026-10-03 07:57:28 +08:00
1 parent 75e2f02590
commit 0a15c6e41d
7 files changed
+99 -18

No files matched your search

+9 -2
View File
@@ -207,7 +207,9 @@ lineup when a bench single exists), 拟态物质 on a pair, 博士投影 (both q
normal operator, 突变细胞 on the least valuable normal operator below 6阶 (deployed or benched — never an elite, never one
of a merge pair, never one already carrying a cell; it comes back after every transformation, and a cell left in temp
gets a hand slot made for it, `makeHandRoom` — on a bot's own seat by destroying the cheapest other hand item if no
chess can be sold, never a human's item under AI 托管), 身份牌 / 通讯机 / 寻呼模块 on a focus member; 画卷 copies the most
chess can be sold, never a human's item under AI 托管 — on such a seat any item left in temp with a full hand gets a
hand slot the same way, a bench operator sold for it; only a hand of nothing but items still drops it, as the temp
deadline would), 身份牌 / 通讯机 / 寻呼模块 on a focus member; 画卷 copies the most
valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops
a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never. 机变: a
bounty card is scored by its expected payout minus its expected leaks × the value of an LP (2 + 20 / LP), the kill
@@ -671,7 +673,12 @@ current leader per player, each threshold once per boss round: the player's dama
the result's 领袖伤害, adds both up; the browser's strip, which plays its queue 5.2 s per line, drops a BOSS_HIT line
once the round it came in is over, and a player's newer BOSS_HIT line of the round replaces their older one, queued or
on screen — public/js/ui/ticker.js `tickerLineLive`, `tickerSupersedes`), CHAR_GIFT (to the receiver only), plus
CUSTOM texts (eliminations, 联防, hidden core).
CUSTOM texts (eliminations, 联防, hidden core — the match-flow notices at `FLOW_TICKER_PRIORITY` 25 [ASSUMED], content
`ticker()` lines at 0). The strip queues by `priority`, highest first and first in first out among equals (research 06
§9.2 "The highest priority wins": BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > GOLDEN_CHAR 2 > CHAR_GIFT 1;
`enqueueTickerLines`); the line on screen is never cut short, and past 4 queued lines the lowest priority's oldest goes.
Until 0.1.1 the queue was first in, first out: the boss prep's shop-level lines held a Final Assault milestone until its
round was over. The official 1 s `broadcastBeginDelay` is not modelled.
---
+3 -1
View File
@@ -221,7 +221,9 @@ function wireNet() {
// same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
const type = typeof msg.type === 'string' ? msg.type : null;
const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null }] }));
// its broadcast priority: the strip plays the highest first (ui/ticker.js enqueueTickerLines)
const priority = Number.isFinite(msg.priority) ? msg.priority : 0;
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text: msg.text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
});
net.on('m.emote', (msg) => {
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
+20 -5
View File
@@ -1,5 +1,6 @@
// Broadcast ticker (m.ticker): a strip under the top bar; each line slides in from the right, stays
// TICKER_MS and leaves; queued lines play in order (only the newest QUEUE_MAX are kept).
// TICKER_MS and leaves; queued lines play by their broadcast priority, first in first out among equals (QUEUE_MAX kept;
// enqueueTickerLines).
// A leader-damage line (BOSS_HIT "{0}博士对敌方领袖造成的伤害超过X%!") is news about the leader in play: it is dropped,
// queued or on screen, once the round it came in is over — the queue can lag a line by up to QUEUE_MAX × TICKER_MS, and
// a Final Assault line must never play over the Hidden Core's fresh leader (player report after 0.1.0: "隐藏boss还没打
@@ -18,7 +19,7 @@ const TICKER_MS = 5200;
const QUEUE_MAX = 4;
/**
* @typedef {{ id: number, text: string, at?: number, type?: string|null, playerId?: string|null, round?: number|null }} TickerLine
* @typedef {{ id: number, text: string, at?: number, type?: string|null, playerId?: string|null, round?: number|null, priority?: number }} TickerLine
*/
/**
@@ -43,9 +44,16 @@ export function tickerSupersedes(line, old) {
&& line.playerId != null && line.playerId === old.playerId && line.round === old.round;
}
/** A line's broadcast priority (activity_table autoChessData.broadcastList `priority`, sent with m.ticker; none = 0). */
const prio = (t) => (t && typeof t.priority === 'number' && Number.isFinite(t.priority) ? t.priority : 0);
/**
* Takes `fresh` lines in: a line that supersedes the one on screen replaces it there, one that supersedes a queued line
* takes that line's place at the back of the queue; the rest queue behind (only the newest `max` are kept).
* replaces that line; every other line queues by priority — research 06 §9.2 "The highest priority wins": BOSS_HIT 30 >
* CHAR_DAMAGE 20 > SHOP_LEVEL 11 > GOLDEN_CHAR 2 > CHAR_GIFT 1, the remake's own notices between (server Match
* FLOW_TICKER_PRIORITY) or 0 — behind the queued lines of its priority or higher (first in, first out among equals). The
* line on screen is never cut short. Past `max` queued lines the lowest priority's oldest goes. Until 0.1.1 the queue
* was first in, first out, so shop-level and promotion lines held a leader-damage line until its round was over.
* @param {TickerLine[]} queue
* @param {TickerLine|null} cur the line on screen
* @param {TickerLine[]} fresh
@@ -58,9 +66,16 @@ export function enqueueTickerLines(queue, cur, fresh, max = QUEUE_MAX) {
if (!t) continue;
if (c && tickerSupersedes(t, c)) { c = t; continue; }
q = q.filter((o) => !tickerSupersedes(t, o));
q.push(t);
let at = q.length;
while (at > 0 && prio(q[at - 1]) < prio(t)) at--;
q.splice(at, 0, t);
}
return { queue: q.slice(-max), cur: c };
while (q.length > max) {
let low = 0;
for (let i = 1; i < q.length; i++) if (prio(q[i]) < prio(q[low])) low = i;
q.splice(low, 1);
}
return { queue: q, cur: c };
}
/**
+17 -7
View File
@@ -147,6 +147,12 @@ import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './
const BOT_REHEARSAL_DEFAULT = 3;
/** Wall-clock ms of bot layout rehearsal per scheduler callback (real time; virtual time runs it in one go). */
const BOT_SLICE_MS = 8;
/**
* Ticker priority of the remake's match-flow notices (隐秘核心已解锁, 联防阶段, a player out or gone): the official lines
* (research 06 §9.2) go BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > GOLDEN_CHAR 2 > CHAR_GIFT 1; ours sit under the
* leader-damage lines [ASSUMED].
*/
export const FLOW_TICKER_PRIORITY = 25;
const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && (t.startsWith('g.') || t.startsWith('b.'))));
const env = (k) => (typeof process !== 'undefined' && process.env ? process.env[k] : undefined);
/** Default combat mode: client-side unless SP_COMBAT=server. */
@@ -506,7 +512,7 @@ export class Match {
}
ps.lp = 0;
ps.eliminate(passedRound);
this.tickerText(`${ps.name}博士中途退出了模拟`);
this.tickerText(`${ps.name}博士中途退出了模拟`, FLOW_TICKER_PRIORITY);
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
@@ -690,9 +696,13 @@ export class Match {
else this.broadcast(msg);
}
tickerText(text) {
/**
* A ticker line of the remake's own (type CUSTOM). `priority`: the match-flow notices (隐秘核心已解锁, 联防阶段, a player out
* or gone) take FLOW_TICKER_PRIORITY so the strip does not hold them behind shop-level lines; other lines 0.
*/
tickerText(text, priority = 0) {
if (!text) return;
this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: 0, playerId: null });
this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: Number(priority) || 0, playerId: null });
}
markPublic() { this._pubDirty = true; }
@@ -1845,7 +1855,7 @@ export class Match {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._defaultWatch();
this.markPublic();
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY);
this._uniteLeftKey = null;
this.runner = new FieldRunner(this, this.fields, {
onTick: (runner) => this._uniteTick(runner),
@@ -2265,7 +2275,7 @@ export class Match {
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
}
this.markPublic();
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY);
}
_finishUniteClient() {
@@ -2746,7 +2756,7 @@ export class Match {
ps.lp = 0;
ps.eliminate(this.round);
this.toast(ps, 'error', '你的目标生命值耗尽,已被淘汰');
this.tickerText(`${ps.name}博士的目标生命值已耗尽`);
this.tickerText(`${ps.name}博士的目标生命值已耗尽`, FLOW_TICKER_PRIORITY);
}
}
this.fields = [];
@@ -3013,7 +3023,7 @@ export class Match {
if (eligible) {
this.hiddenReached = true;
this.bossPool = null;
this.tickerText('隐秘核心已解锁');
this.tickerText('隐秘核心已解锁', FLOW_TICKER_PRIORITY);
this.startRound(this.gd.hiddenRound);
} else {
this.finish({ victory, reason: victory ? 'victory' : 'defeat' });
+3 -2
View File
@@ -1742,8 +1742,9 @@ function resolveTemp(m, ps) {
if (j >= 0) tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }));
}
} else if (p.kind === 'item') {
// 突变细胞 comes back after every transformation (昆图斯's strategy item): make room in the hand rather than lose it
if (isMutationCell(m.gd, p.id) && makeHandRoom(m, ps)) {
// 突变细胞 comes back after every transformation (昆图斯's strategy item), and a human's item under AI 托管 is theirs:
// make room in the hand (sell a bench operator) rather than lose it — only a hand of nothing but items still drops it
if ((isMutationCell(m.gd, p.id) || ps.autoplay) && makeHandRoom(m, ps)) {
const j = freeSlot(ps.hand);
if (j >= 0 && tryDo(() => ps.move(p.uid, { area: 'hand', idx: j }))) continue;
}
+15
View File
@@ -334,6 +334,21 @@ test('#4 the transformation completes a merge on the board: the cell is returned
m.dispose();
});
test('#10 AI 托管 never destroys a human\'s item to clear temp: a bench operator is sold for its slot instead', () => {
const s = setup({ seed: 34 });
const { m, ps } = s;
ps.autoplay = true; // a human seat under AI 托管 (ps.isBot stays false)
const elites = [1, 2, 3, 4, 5, 6].flatMap((t) => plainOf(m, t)).map((id) => m.gd.goldenIdOf(id)).filter((g) => g && m.pool.left(m.gd.baseIdOf(g)) >= m.gd.goldenCopies);
while (ps.hand.some((x) => x == null)) give(m, ps, elites.shift(), 'hand');
const item = Object.values(DATA.items).find((it) => it && it.id !== CELL && !it.isGolden && it.price > 0 && /_e_a$/.test(it.id));
giveItem(m, ps, item.id, 'temp');
botPrepEnd(m, ps);
assert.equal(ownedItems(ps, item.id).length, 1, `${item.name}: kept (it used to be destroyed when the hand was full)`);
assert.ok(ps.tempEmpty, 'temp resolved');
checkInvariants(m);
m.dispose();
});
test('#4 a bot never destroys the cell: left in temp with a full hand and nobody to inject, it gets a hand slot', () => {
const s = setup({ seed: 33 });
const { m, ps } = s;
+32 -1
View File
@@ -147,10 +147,41 @@ test('timeline: a Final Assault burst shows its top milestone before the round e
assert.equal(shown.find((s) => s.text === '隐秘核心已解锁').at, 5.5, 'the Hidden Core\'s unlock line shows as its round starts');
});
test('lines queue by broadcast priority (research 06 §9.2 "The highest priority wins"), first in first out among equals; the lowest priority\'s oldest goes past QUEUE_MAX', () => {
const P = { BOSS_HIT: 30, CHAR_DAMAGE: 20, SHOP_LEVEL: 11, GOLDEN_CHAR: 2, CHAR_GIFT: 1, CUSTOM: 0 };
const pl = (type, text, priority = P[type]) => ({ type, round: 14, text, priority });
const shop1 = pl('SHOP_LEVEL', 's1'), gold = pl('GOLDEN_CHAR', 'g'), shop2 = pl('SHOP_LEVEL', 's2');
const boss = { ...hit('A', 20, 14), priority: 30 }, dmg = pl('CHAR_DAMAGE', 'd'), flow = pl('CUSTOM', '隐秘核心已解锁', 25);
let r = enqueueTickerLines([], pl('CUSTOM', 'on screen'), [shop1, gold, shop2]);
assert.deepEqual(texts(r.queue), ['s1', 's2', 'g']);
r = enqueueTickerLines(r.queue, r.cur, [boss]);
assert.equal(r.cur.text, 'on screen', 'the line on screen is never cut short');
assert.deepEqual(texts(r.queue), [boss.text, 's1', 's2', 'g'], 'the leader-damage line jumps the queue');
r = enqueueTickerLines(r.queue, r.cur, [dmg, flow]);
assert.deepEqual(texts(r.queue), [boss.text, '隐秘核心已解锁', 'd', 's2'], 'flow notice under the leader line, then operator damage; past QUEUE_MAX the promotion line, then the older shop line go');
// the superseding rule still holds under priorities
r = enqueueTickerLines(r.queue, r.cur, [{ ...hit('A', 50, 14), priority: 30 }]);
assert.deepEqual(texts(r.queue).slice(0, 2), ['A博士对敌方领袖造成的伤害超过50%!', '隐秘核心已解锁']);
assert.equal(r.queue.filter((t) => t.type === 'BOSS_HIT').length, 1);
});
test('timeline with priorities: shop-level and promotion lines of the boss prep no longer hold the Final Assault\'s milestones until the round ends', () => {
const pr = (o, priority) => ({ ...o, priority });
const shown = playStrip([
{ at: 0, round: 14, lines: [pr(line('SHOP_LEVEL', null, 'B6'), 11), pr(line('SHOP_LEVEL', null, 'C6'), 11), pr(line('GOLDEN_CHAR', null, 'Bgold'), 2), pr(line('SHOP_LEVEL', null, 'D6'), 11)] },
{ at: 1, round: 14, lines: [pr(hit('A', 20), 30)] },
{ at: 4, round: 14, lines: [pr(hit('A', 50), 30)] },
{ at: 12, round: 15, lines: [pr(line('CUSTOM', null, '隐秘核心已解锁'), 25)] },
], 40);
const r14 = shown.filter((s) => s.round === 14).map((s) => s.text);
assert.deepEqual(r14, ['B6', 'A博士对敌方领袖造成的伤害超过50%!', 'C6'], `the milestone plays second (${r14.join(' | ')})`);
assert.equal(shown.find((s) => s.text === '隐秘核心已解锁').round, 15);
});
test('main.js stamps every ticker line with its type, player and round; the strip is built on the helpers', () => {
const main = readFileSync(new URL('../../public/js/main.js', import.meta.url), 'utf8');
const handler = main.slice(main.indexOf("net.on('m.ticker'"), main.indexOf("net.on('m.emote'"));
assert.match(handler, /type, playerId, round:\s*s\.match\?\.public\?\.round/, 'the m.ticker handler keeps type, player and round');
assert.match(handler, /type, playerId, round:\s*s\.match\?\.public\?\.round \?\? null, priority/, 'the m.ticker handler keeps type, player, round and priority');
const strip = readFileSync(new URL('../../public/js/ui/ticker.js', import.meta.url), 'utf8');
const comp = strip.slice(strip.indexOf('export function Ticker('));
assert.match(comp, /useStore\(\(s\) => s\.match\?\.public\?\.round/, 'the strip follows m.public.round');