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:
1 parent
75e2f02590
commit
0a15c6e41d
7 files changed
+99
-18
No files matched your search
+9
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user