diff --git a/public/i18n/en.json b/public/i18n/en.json index 2c5022a6..d63f297a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -836,6 +836,8 @@ "终极": "Ultimate", "rounds::回合": "Rounds", "{who}:获得{name}": "{who}: obtained {name}", + "{who}:卡池中已没有{name}": "{who}: no {name} left in the shared pool", + "{who}:没有可获得的同盟约干员": "{who}: no Operator of the same Alliance can be obtained", "获得{name}": "Obtained {name}", "联防阶段:{names} 迎战突破防线的敌人": "Unite Phase: {names} take on the enemies that broke through", "地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 84e27269..84ef6b5e 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -836,6 +836,8 @@ "终极": "究極", "rounds::回合": "ラウンド", "{who}:获得{name}": "{who}:{name}を獲得", + "{who}:卡池中已没有{name}": "{who}:共有プールに{name}が残っていません", + "{who}:没有可获得的同盟约干员": "{who}:入手できる同じ盟約のオペレーターがいません", "获得{name}": "{name}を獲得", "联防阶段:{names} 迎战突破防线的敌人": "防衛支援段階:{names}が防衛線を突破した敵を迎え撃つ", "地形变化:{names}无法停留在原位置,已撤回整备区": "地形変化:{names}は元の位置に留まれないため、整備エリアに戻りました", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index e6d14925..ca752797 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -832,6 +832,8 @@ "终极": "초월", "rounds::回合": "라운드", "{who}:获得{name}": "{who}: {name} 획득", + "{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다", + "{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다", "获得{name}": "{name} 획득", "联防阶段:{names} 迎战突破防线的敌人": "연합 방어 단계: {names} 님이 방어선을 돌파한 적을 맞아 싸웁니다", "地形变化:{names}无法停留在原位置,已撤回整备区": "지형 변화: {names} 오퍼레이터가 원래 위치에 머무를 수 없어 정비 구역으로 돌아갔습니다", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 090279fa..eab20f45 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -835,6 +835,8 @@ "终极": "終極", "rounds::回合": "回合", "{who}:获得{name}": "{who}:獲得{name}", + "{who}:卡池中已没有{name}": "{who}:卡池中已沒有{name}", + "{who}:没有可获得的同盟约干员": "{who}:沒有可獲得的同盟約幹員", "获得{name}": "獲得{name}", "联防阶段:{names} 迎战突破防线的敌人": "聯防階段:{names} 迎戰突破防線的敵人", "地形变化:{names}无法停留在原位置,已撤回整备区": "地形變化:{names}無法停留在原位置,已撤回整備區", diff --git a/server/match/builtinMeta.js b/server/match/builtinMeta.js index 59a916d9..2815de11 100644 --- a/server/match/builtinMeta.js +++ b/server/match/builtinMeta.js @@ -29,6 +29,7 @@ import { getData } from '../data.js'; import { itemKey } from './gamedata.js'; +import { msg, dn } from '../../shared/i18n.js'; const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d); @@ -38,6 +39,15 @@ const paramsOf = (ctx, item) => { return rec && rec.params && typeof rec.params === 'object' ? rec.params : {}; }; +/** + * The item gave nothing — the copies are all owned (the shared pool, research 06 §7) or no operator shares the bond — but + * it is still destroyed ("装备时销毁"): say why instead of swallowing it (GitHub #401, the owner's OK of 2026-10-09). + */ +function toastNothing(ctx, ev, chessName = null) { + const who = ctx.gd.item(ev.item?.id)?.name || ''; + ctx.toast(chessName ? msg('{who}:卡池中已没有{name}', { who: dn(who), name: dn(chessName) }) : msg('{who}:没有可获得的同盟约干员', { who: dn(who) }), 'warn'); +} + /** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */ function rollSameBond(ctx, bonds, maxTier, exclude = null) { const set = new Set(bonds); @@ -100,10 +110,12 @@ const ITEM_HANDLERS = { onEquip(ctx, ev) { const n = Math.max(1, int(paramsOf(ctx, ev.item).count, 1)); const bonds = ctx.pieceBonds(ev.target.uid); + let got = 0; for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel()); - if (id) ctx.grantChess(id); + if (id && ctx.grantChess(id)) got++; } + if (!got) toastNothing(ctx, ev); }, }, use_equip_gain_coin_when_next_round_start: { @@ -138,9 +150,12 @@ const ITEM_HANDLERS = { onEquip(ctx, ev) { const base = ctx.gd.baseIdOf(ev.target.id); const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length; - if (owned >= 2) { ctx.grantChess(base); return; } + if (owned >= 2) { + if (!ctx.grantChess(base)) toastNothing(ctx, ev, ctx.gd.chess(base)?.name || null); + return; + } const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6); - if (id) ctx.grantChess(id); + if (!id || !ctx.grantChess(id)) toastNothing(ctx, ev); }, }, use_equip_reward_special_goods_char_chess: { @@ -151,6 +166,7 @@ const ITEM_HANDLERS = { // a pick-one offer never shows one operator twice (user playtest #6 item 19); fewer cards when the pool runs out for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel(), ids); if (id) ids.push(id); } if (ids.length) ctx.offerChess(ids, { source: 'item' }); + else toastNothing(ctx, ev); }, }, // 信标 (act2autochess eff_acarm109 / eff_acgarm109 "装备时,目标干员和本装备销毁并进行一次特殊刷新,出现两名与携带者同等阶的 diff --git a/server/sim/content/items/meta.js b/server/sim/content/items/meta.js index ac4fedf8..ceed8a39 100644 --- a/server/sim/content/items/meta.js +++ b/server/sim/content/items/meta.js @@ -33,6 +33,7 @@ import { itemKeyOf, isCoreBond } from '../support/index.js'; import { metaBonds } from '../support/meta.js'; +import { msg, dn } from '../../../../shared/i18n.js'; const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d); @@ -102,6 +103,8 @@ export function registerMeta(registry) { if (id) ids.push(id); } if (ids.length) ctx.offerChess(ids, { source: 'item' }); + // nothing to offer (e.g. 缪尔赛思's 调和 below shop level 6): still destroyed — say why (GitHub #401, builtinMeta toastNothing) + else ctx.toast(msg('{who}:没有可获得的同盟约干员', { who: dn(ctx.gd.item(ev.item?.id)?.name || '') }), 'warn'); }, })); diff --git a/test/content/feedback5-cb1-pins.test.js b/test/content/feedback5-cb1-pins.test.js index e46a0b26..dd4b0cc8 100644 --- a/test/content/feedback5-cb1-pins.test.js +++ b/test/content/feedback5-cb1-pins.test.js @@ -49,7 +49,7 @@ test('50: two 空弦 made 阿戈尔 (变形同构体 + 阿戈尔重刃) in a row } }); -test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 — nothing qualifies (调和, tier ≤ shop level), the item is used up', () => { +test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 — nothing qualifies (调和, tier ≤ shop level), the item is used up with a toast saying why', () => { for (const itemId of ['chess_item_4_01_e_a', 'chess_item_2_06_e_a']) { for (const level of [5, 6]) { const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 7, fake: true }).start(); @@ -60,11 +60,20 @@ test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 — const item = ps.acquireItem(itemId, { source: 'test' }); const owned = () => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((p) => p && p.id === 'chess_char_6_11_a').length; const n0 = owned(), offers0 = ps.offers.length; + const warns = []; + const toast0 = h.m.toast.bind(h.m); + h.m.toast = (who, kind, text) => { if (kind === 'warn') warns.push(text?.msgid ?? text); return toast0(who, kind, text); }; assert.deepEqual(h.m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: mly.uid }), { ok: true }); assert.equal(ps.find(item.uid), null, `${itemId} @${level}: consumed`); const got = owned() - n0 + (ps.offers.length - offers0); - if (level < 6) assert.equal(got, 0, `${itemId} @${level}: nothing (no 调和 operator of tier ≤ ${level})`); - else assert.equal(got, 1, `${itemId} @6: 缪尔赛思 (granted, or offered by 寻呼模块)`); + // used up with nothing: a toast says why (GitHub #401, the owner's OK of 2026-10-09) + if (level < 6) { + assert.equal(got, 0, `${itemId} @${level}: nothing (no 调和 operator of tier ≤ ${level})`); + assert.deepEqual(warns, ['{who}:没有可获得的同盟约干员'], `${itemId} @${level}: the toast`); + } else { + assert.equal(got, 1, `${itemId} @6: 缪尔赛思 (granted, or offered by 寻呼模块)`); + assert.deepEqual(warns, [], `${itemId} @6: no toast`); + } } } }); diff --git a/test/content/items.test.js b/test/content/items.test.js index 852c5d6b..4776ea38 100644 --- a/test/content/items.test.js +++ b/test/content/items.test.js @@ -741,6 +741,16 @@ const plain = (pred = () => true) => Object.values(DATA.chess) .filter((c) => c.visible && !c.isGolden && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c)) .map((c) => c.chessId).sort(); /** Record the chess granted by item effects (acquireChess with an `item:` source). */ +/** The warn toasts sent to the match's players from now on: [{ msgid, who, name }] (server msg() objects). */ +function spyWarns(m) { + const got = []; + const orig = m.toast.bind(m); + m.toast = (ps, kind, text) => { + if (kind === 'warn') got.push({ msgid: text?.msgid ?? text, who: text?.params?.who?.dn ?? null, name: text?.params?.name?.dn ?? null }); + return orig(ps, kind, text); + }; + return got; +} function spyGrants(ps) { const got = []; const orig = ps.acquireChess.bind(ps); @@ -958,11 +968,14 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing assert.equal(m.pool.left(cid), 0, 'elite 3 + 2 normal = the pool cap 5'); const before = chessIn(ps); const got = spyGrants(ps); + const warns = spyWarns(m); const it = giveItem(m, ps, item); assert.deepEqual(equip(it, t), OK, item); assert.deepEqual(got, [], `${item}: nothing granted`); assert.deepEqual(chessIn(ps), before, `${item}: no other operator, no second elite`); assert.ok(!ps.find(it.uid), `${item}: consumed`); + // …and says why (GitHub #401, the owner's OK of 2026-10-09) + assert.deepEqual(warns, [{ msgid: '{who}:卡池中已没有{name}', who: '拟态物质', name: '溯光星源' }], `${item}: the toast`); } // 2 normal copies, no elite, the pool drained by other players: nothing either { @@ -971,9 +984,11 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing give(m, ps, cid, 'hand'); m.pool.take(cid, m.pool.left(cid)); const got = spyGrants(ps); + const warns = spyWarns(m); assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK); assert.deepEqual(got, []); assert.deepEqual(chessIn(ps), [cid, cid]); + assert.deepEqual(warns.map((w) => w.msgid), ['{who}:卡池中已没有{name}']); } // 2 normal copies with copies left: the 3rd merges into the elite { @@ -981,12 +996,27 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing const t = give(m, ps, cid, 'hand'); give(m, ps, cid, 'hand'); assert.equal(m.pool.left(cid), 3); + const warns = spyWarns(m); assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK); assert.deepEqual(chessIn(ps), [elite]); + assert.deepEqual(warns, [], 'a grant: no warn toast'); assert.equal(m.pool.left(cid), 2, 'the elite holds 3 copies'); } }); +test('拟态物质 with fewer than 2 copies and no same-bond operator left gives nothing and says so (GitHub #401)', () => { + // 缪尔赛思's only bond is 调和 and she is its only member: one copy owned, the rest of her pool taken by others + const cid = 'chess_char_6_11_a'; + const { m, ps, equip } = setup({ seed: 9 }); + const t = give(m, ps, cid, 'hand'); + m.pool.take(cid, m.pool.left(cid)); + const got = spyGrants(ps); + const warns = spyWarns(m); + assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK); + assert.deepEqual(got, [], 'nothing granted'); + assert.deepEqual(warns, [{ msgid: '{who}:没有可获得的同盟约干员', who: '拟态物质', name: null }]); +}); + test('博士投影: golden promotes at once; normal stays equipped and promotes at the next round start', () => { const { h, m, ps, equip } = setup(); const cid = plain((c) => c.tier === 2)[0];