From 0a15c6e41d7262f573159b4893cb86e74d764e22 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 07:57:28 +0800 Subject: [PATCH] =?UTF-8?q?feedback1=20integrate:=20ticker=20lines=20by=20?= =?UTF-8?q?broadcast=20priority;=20AI=20=E6=89=98=E7=AE=A1=20keeps=20a=20h?= =?UTF-8?q?uman's=20items=20out=20of=20temp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/META.md | 11 +++++++++-- public/js/main.js | 4 +++- public/js/ui/ticker.js | 25 ++++++++++++++++++----- server/match/Match.js | 24 +++++++++++++++------- server/match/bot.js | 5 +++-- test/match/feedback1-meta.test.js | 15 ++++++++++++++ test/ui/bosshit-ticker.test.js | 33 ++++++++++++++++++++++++++++++- 7 files changed, 99 insertions(+), 18 deletions(-) diff --git a/docs/META.md b/docs/META.md index 57806569..00e10680 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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. --- diff --git a/public/js/main.js b/public/js/main.js index 75fafcc3..35ea7ac9 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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() }] })); diff --git a/public/js/ui/ticker.js b/public/js/ui/ticker.js index f44c08d1..cb1380e5 100644 --- a/public/js/ui/ticker.js +++ b/public/js/ui/ticker.js @@ -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 }; } /** diff --git a/server/match/Match.js b/server/match/Match.js index 33cd2db5..f0d0af65 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -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' }); diff --git a/server/match/bot.js b/server/match/bot.js index ea1a3fab..75f636f7 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -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; } diff --git a/test/match/feedback1-meta.test.js b/test/match/feedback1-meta.test.js index ee4ec7a3..6b184ab9 100644 --- a/test/match/feedback1-meta.test.js +++ b/test/match/feedback1-meta.test.js @@ -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; diff --git a/test/ui/bosshit-ticker.test.js b/test/ui/bosshit-ticker.test.js index a64afc83..8c90492a 100644 --- a/test/ui/bosshit-ticker.test.js +++ b/test/ui/bosshit-ticker.test.js @@ -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');