linkchat-extension-spire 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -9
  2. package/index.js +321 -251
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
- # Spire for LinkChat — 0.3.0
1
+ # Spire for LinkChat — 0.4.2
2
2
 
3
3
  按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);推荐使用 **0.7.4 及以上**以包含备用屏幕行定位修复;路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-4`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
4
4
 
5
- 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.3.0.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
5
+ 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.4.2.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
6
6
 
7
7
  ## 操作
8
8
 
9
- **战斗**:我方在左、敌人在右(默认 80×25 终端使用紧凑双栏,队友在左、敌人在右;有效宽度不足 68 列或高度不足 24 行时改为上下分区);每个信息块自上而下是意图、名称、格挡与血条、生命、增益、减益,多个意图字段会换行而不是被截断。手牌竖排并编号 1–90:数字键只把光标移到该位置,Enter 才是执行;每页最多十项,PgUp/PgDn 翻页,方向键跨页时页码同步。能量右侧是固定的 `[E 结束回合]` 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
9
+ **战斗**:我方在左、敌人在右(只保留左右分组和上下排列两种布局;至少 68×24 时按 G 切换,窗口不足时自动上下排列,放大后恢复所选布局);每个信息块自上而下是意图、名称、格挡与血条、生命、BuffDeBuff,多个意图字段会换行而不是被截断。手牌竖排,选中项显示箭头,不显示列表序号;方向键选择、Enter 执行;方向键跨页时列表自动跟随焦点。能量右侧是固定的 `[E 结束回合 X]`(X 为当前回合数) 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
10
10
 
11
11
  **选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
12
12
 
13
- **地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
13
+ **地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,方向键在地图边缘继续移动可进入装备或顶部入口。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
14
14
 
15
15
  **商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
16
16
 
@@ -18,9 +18,15 @@
18
18
 
19
19
  **宝箱**:单人时点选遗物即为一次明确的领取,客户端会在服务端确认后接着领取同一件遗物(按 reward ID 精确匹配,结果不明时不会猜);多人保留投票、分配、领取三个阶段,投票按钮本身就是确认。
20
20
 
21
- **其他**:Tab 依次查看行动、牌组、遗物、药水、状态、日志,标签随窗口宽度换行;抽牌堆、消耗堆和弃牌堆仅在战斗中显示于能量栏下一行,依次位于左、中、右,点击或按 A / S / D 查看(药水面板的 D 仍为丢弃药水),Esc 返回;Esc 返回或暂停退出;药水面板 D 丢弃药水;多选可用数字键定位、空格勾选、确认行提交,规则允许空选时会给出明确的取消项。请求结果不明时按 R 重试同一操作,不会重复出牌。
21
+ **装备与详情**:顶部常驻本人遗物、药水实际槽位(含空槽)及“牌组 N · D”。遗物显示计数;过长的栏可横向翻页。悬停或方向键选择在底部显示完整说明,移出恢复键盘焦点;`?` 查看全文。药水点击或 Enter 打开“使用/丢弃”,丢弃需确认;自动药水及事件锁定状态会明确标注。火堆升级列表悬停或键盘选中即可查看升级前后,确认才执行升级。
22
22
 
23
- **鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部面板名切换面板;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
23
+ **快捷键**:`D` 总牌组、`A` 抽牌堆、`S` 弃牌堆、`X` 消耗堆、`M` 地图、`E` 结束回合、`V` 完整战斗日志、`G` 切换战斗布局。地图仍用 `C` 定位当前位置。方向键按画面位置在主操作、单位、装备、牌堆和顶部入口间移动;Enter 确认,Esc 返回主操作或关闭弹窗。Tab、PageUp、PageDown 已移除。牌堆查看、日志与弹窗用 Esc 返回并恢复位置。`D` 不再丢弃药水。请求结果不明时 `R` 重试原操作。
24
+
25
+ **战斗日志**:每场独立保存完整记录,战后结算保留至下一战;常规快照仅带最近 20 条。默认隐藏底部日志预览;`V` 或点击顶部“战斗日志”打开按回合分组的完整记录,最新记录在最上方,打开从顶部开始,↑↓ / 滚轮滚动,在记录首尾继续按 ↑↓ 自动读取相邻页,← 较新 / → 较旧读取前后页(每页 100 条)。抽牌等私有信息仅本人可见,不显示洗牌顺序;旧局早期日志可能不完整。敌人意图按原作声明顺序显示剑、盾、强化、减益等符号,攻击显示对本人伤害与次数,隐藏底层执行参数。
26
+
27
+ 列表与地图节点前均不显示数字定位标号。
28
+
29
+ **鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部牌组或日志入口打开查看;遗物悬停查看,药水点击打开操作菜单;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
24
30
 
25
31
  ## 内容
26
32
 
@@ -32,10 +38,14 @@
32
38
 
33
39
  扩展只提供游戏界面,存档在服务器,卸载扩展不会删除存档。正式客户端 0.6.3 不包含扩展功能。此版本为正式版。
34
40
 
35
- ## 协议 5 配套更新
41
+ ## 协议 6 配套更新
36
42
 
37
- 本版必须与协议 5 的 Spire 后端一起更新。协议 1–4 会明确提示版本不匹配并阻止游戏操作。聊天 CLI 主程序无需重新发布。
43
+ 本版必须与协议 6 的 Spire 后端一起更新。协议 1–4 会明确提示版本不匹配并阻止游戏操作。聊天 CLI 主程序无需重新发布。
38
44
 
39
45
  升级预览按当前牌堆读取卡牌,保留战斗中的临时费用;队友面板关闭后恢复原选择。水晶球支持方向键、Enter、空格和鼠标操作。
40
46
 
41
- 进阶 A0–A10 全部开放,默认 A0。在创建房间前选择“新局难度”,创建后固定;继续已有单人存档保留原等级。扩展 0.3.0 使用游戏协议 5,需要配套后端。
47
+ 进阶 A0–A10 全部开放,默认 A0。在创建房间前选择“新局难度”,创建后固定;继续已有单人存档保留原等级。扩展 0.4.2 使用游戏协议 6,需要配套后端。
48
+
49
+ 悬停详情靠窗口底部对齐,长说明最多预览三行,`?` 查看全文。
50
+
51
+ 单位格挡值以盾牌显示(例如 `⛨ 12`),我方、敌人及详情使用相同格式。
package/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- const PROTOCOL = 5;
2
+ const PROTOCOL = 6;
3
3
  const VERSION_ERROR = 'Spire 客户端与服务器版本不匹配,请配套升级。';
4
4
  const BASE = '/api/games/spire/rooms';
5
5
  const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
6
6
  export const phases = { lobby: '等待队友', map: '路线投票', combat: '战斗', reward: '战利品', rest: '休息', shop: '商店', event: '事件', treasure: '宝箱', actComplete: '本幕完成', ending: '结局事件', victory: '通关', defeat: '攀登结束', abandoned: '已放弃' };
7
- const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
8
7
  const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
9
8
  const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
10
9
  Object.assign(powerNames, {juggling:'杂耍',aggression:'侵略',barricade:'壁垒',corruption:'腐化',colossus:'巨像',crimsonMantle:'猩红披风',cruelty:'残酷',inferno:'炼狱',juggernaut:'势不可挡',pyre:'柴堆',plating:'覆甲',unmovable:'坚如磐石',freeAttack:'免费攻击',vicious:'凶狠',tank:'坦克',guarded:'被守护',covered:'被掩护',intercept:'拦截',oneTwoPunch:'连环拳',stampede:'奔踏',calamity:'灾祸',tagTeam:'接力',knockdown:'击倒',beaconOfHope:'希望灯塔',fasten:'紧固',prepTime:'准备时间',retainHand:'保留手牌',nostalgia:'怀旧',automation:'自动化',panache:'华丽',bomb:'炸弹',rollingBoulder:'滚石',theGambit:'赌局',coordinate:'协同',blockNextTurn:'下回合格挡',setupStrike:'临时力量',vigor:'活力',artifact:'人工制品',poison:'中毒',strangle:'绞杀',freeSkill:'免费技能',drawNextTurn:'下回合抽牌',focus:'集中',temporaryFocus:'临时集中',freePower:'免费能力',energyNextTurn:'下回合能量',temporaryStrengthDown:'临时力量降低',doom:'灾厄',hang:'绞刑',debilitate:'衰弱',sicEm:'咬住',veilpiercer:'虚无免费'});
@@ -117,9 +116,6 @@ function place(cells, width) {
117
116
  }
118
117
  return { text: cut(out, width), placed };
119
118
  }
120
- function mapRouteIndex(id, options) {
121
- return (options ?? []).findIndex(row => row.action?.type === 'vote' && row.action.node === id);
122
- }
123
119
  // One glyph per set of directions, so a corner always joins the lines that meet.
124
120
  const JOIN = {
125
121
  u: '│', d: '│', l: '─', r: '─', ud: '│', ul: '┘', ur: '└', dl: '┐', dr: '┌', lr: '─',
@@ -132,8 +128,7 @@ const joinGlyph = directions => {
132
128
  };
133
129
  const mapCode = node => `${String(node.row).padStart(2, '0')}-${String.fromCharCode(65 + node.col)}`;
134
130
  function mapLabel(view, data, node, options) {
135
- const route = mapRouteIndex(node.id, options);
136
- return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}${route >= 0 ? route + 1 : ''}[${node.type}]`;
131
+ return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}[${node.type}]`;
137
132
  }
138
133
  function mapPaint(view, data, node, label, focus) {
139
134
  const child = data.byId.get(focus)?.children?.includes(node.id);
@@ -313,9 +308,72 @@ export function createScreen(ctx) {
313
308
  const me = () => state?.players.find(p => p.id === ctx.user.id);
314
309
  let characterConfirmed=false, mapOpen, mapReturnTab=0;
315
310
  const needsCharacterChoice=()=>!characterConfirmed && !state?.paused && ['lobby','map'].includes(state?.phase) && state?.options.some(row=>row.action?.type==='character');
316
- let pileView;
317
- const activeTab = () => pileView ?? tabs[tab];
318
- function openPile(key) { if(state?.phase!=='combat' || modal)return; pileView=key; index=0;selected=undefined;listPage=0;changed(); }
311
+ let pileView, pileReturn, focusRegion='action', focusIndex=0, hovered, focusControl;
312
+ const equipmentOffset={relics:0,potions:0};
313
+ const activeTab = () => pileView ?? 'action';
314
+ function closePile() {
315
+ pileView=undefined;
316
+ if(pileReturn){({index,selected,listPage,focusRegion,focusIndex,mapOpen}=pileReturn);pileReturn=undefined;}
317
+ changed();
318
+ }
319
+ function openPile(key) {
320
+ if(!state || modal || (key!=='deck' && state.phase!=='combat'))return;
321
+ if(!pileView)pileReturn={index,selected,listPage,focusRegion,focusIndex,mapOpen};
322
+ pileView=key;mapOpen=false;focusRegion='action';hovered=undefined;index=0;selected=undefined;listPage=0;changed();
323
+ }
324
+ const regionItems = region => !state ? [] : region==='relics' ? state.self.relics.map(r=>({...r,id:r.id??r.key})) :
325
+ region==='potions' ? Array.from({length:state.self.potionCapacity??3},(_,i)=>state.self.potions[i]??{id:`empty:${i}`,name:'空槽',description:'没有药水',empty:true}) :
326
+ region==='units' ? [...state.players,...state.enemies.filter(e=>e.hp>0||e.awaitingRevive)] :
327
+ region==='entries' ? [{id:'deck',name:`牌组 ${state.self.deck.length} · D`,description:'查看永久总牌组 · U 升级预览'},{id:'log',name:'战斗日志 · V',description:'查看当前战斗完整记录'}] : [];
328
+ function focusItem(){return regionItems(focusRegion)[focusIndex];}
329
+ function detailText(){
330
+ const row=hovered?.row ?? (focusRegion==='action' ? items()[index] : focusItem());
331
+ if(!row)return '方向键移动焦点 · Enter 操作 · Esc 返回 · ? 完整说明';
332
+ if(row.hp!==undefined)return `${row.name} · ${hpText(row)} HP · ⛨ ${row.block??0} · ${row.intent?intentLines(row.intent,row,state).join(' · '):resourcesText(row)} · ${powersText(row.powers,row.monologueInstances)}`;
333
+ return [row.name??row.card?.name,row.description??row.card?.description??row.label,
334
+ row.counter!==undefined?`当前计数 ${row.counter}`:'',row.usage==='Automatic'?'自动触发,不能主动使用':'',
335
+ (hovered?.region??focusRegion)==='potions' && state?.self.potionsLocked?'事件锁定:不能使用或丢弃':''].filter(Boolean).join(' · ');
336
+ }
337
+ function potionMenu(potion){
338
+ if(!potion || potion.empty)return;
339
+ const id=potion.id;
340
+ choose(`${potion.name} · 药水操作`,[{id:'use',label:'使用'},{id:'drop',label:'丢弃'},{id:'back',label:'返回'}],answer=>{
341
+ const current=state.self.potions.find(p=>p.id===id);
342
+ if(!current){note='这瓶药水已不在栏中。';changed();return;}
343
+ if(answer.id==='back'){changed();return;}
344
+ if(state.self.potionsLocked){note='事件锁定:不能使用或丢弃药水。';changed();return;}
345
+ if(!canAct())return;
346
+ if(answer.id==='drop'){
347
+ choose('丢弃这瓶药水?',[{id:'no',label:'保留'},{id:'yes',label:'丢弃'}],row=>{if(row.id==='yes')void submit({type:'dropPotion',potion:id});else changed();});return;
348
+ }
349
+ if(current.usage==='Automatic'){note='这瓶药水会自动触发,不能主动使用。';changed();return;}
350
+ if(state.paused || (state.phase==='combat'?me()?.done:current.usage!=='AnyTime')){note='当前不能使用这瓶药水。';changed();return;}
351
+ const action={type:'potion',potion:id,phaseId:state.phaseId,label:current.name};
352
+ if(current.target==='AnyEnemy'||current.key==='FirePotion')beginTargeting(action,'enemy','选择药水目标');
353
+ else if(current.target==='AnyPlayer'&&state.players.filter(p=>p.hp>0).length>1)beginTargeting(action,'ally','选择药水目标');
354
+ else {delete action.label;void submit(action);}
355
+ });
356
+ }
357
+ async function openLog(cursor={},atEnd=false){
358
+ if(!state || !roomId)return;
359
+ const battle=state.battleLog?.battleId, originRoom=roomId;
360
+ if(!battle){note='当前没有战斗日志。';changed();return;}
361
+ const previous=modal?.battleLog ? {selected:modal.previousSelected,index:modal.previousIndex,page:modal.previousPage} : {selected,index,page:listPage};
362
+ await guard(async()=>{
363
+ const pageCursor=Object.keys(cursor).length?cursor:{before:String((state.battleLog?.last??Number.MAX_SAFE_INTEGER-1)+1)};
364
+ const query=new URLSearchParams({battleId:String(battle),limit:'100',...pageCursor});
365
+ const body=await ctx.request(`${BASE}/${originRoom}/logs?${query}`);
366
+ if(roomId!==originRoom || state.battleLog?.battleId!==battle)return;
367
+ const rows=[];let turn;
368
+ for(const entry of [...body.entries].reverse().filter(entry=>entry.kind!=='pile')){
369
+ if(entry.turn!==turn){turn=entry.turn;rows.push({id:`turn:${entry.id}`,label:`── 回合 ${turn} ──`,separator:true});}
370
+ rows.push({id:String(entry.id),label:`${entry.id}. ${entry.text}${entry.source?.name?` [${entry.source.name}]`:''}`});
371
+ }
372
+ choose(`战斗日志 · ${body.total} 条${body.incomplete?' · 早期记录可能不完整':''} · ← 较新 / → 较旧`,rows.length?rows:[{id:'empty',label:'暂无记录'}],()=>changed());
373
+ Object.assign(modal,{battleLog:body,previousSelected:previous.selected,previousIndex:previous.index,previousPage:previous.page});
374
+ if(atEnd)moveTo(rows.length-1);
375
+ });
376
+ }
319
377
  const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
320
378
  // A phase this build cannot render stays read only, so nothing is submitted.
321
379
  const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
@@ -326,10 +384,7 @@ export function createScreen(ctx) {
326
384
  if (!state) return [];
327
385
  if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
328
386
  if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
329
- if (activeTab() === 'relics') return state.self.relics.map(r => ({ id: r.id ?? r.key, label: `${r.name}:${r.description}` }));
330
- if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
331
- if (activeTab() === 'log') return logItems();
332
- if (activeTab() === 'status') return [...state.players,...state.enemies.filter(e=>e.hp>0 || e.awaitingRevive)].map(p=>({id:String(p.id),label:`${p.name} ${hpText(p)} HP · 格挡 ${p.block} · ${powersText(p.powers, p.monologueInstances)}${resourcesText(p) ? ` · ${resourcesText(p)}` : ''}`}));
387
+
333
388
  if (state.pendingChoice && !state.paused) return state.options.map(row => row.pick ? {...row,label:`${picked.has(row.pick) ? '[x]' : '[ ]'} ${row.label}`} : row.id === 'confirmChoice' ? {...row,label:`确认选择(${picked.size}/${state.pendingChoice.maximum})`} : row);
334
389
  if (state.pendingDecision && !state.paused) {
335
390
  const decision = state.pendingDecision;
@@ -403,11 +458,16 @@ export function createScreen(ctx) {
403
458
  mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
404
459
  }
405
460
  if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
406
- modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
461
+ modal = undefined; mapOpen=undefined; pileView=undefined;pileReturn=undefined;focusRegion='action';focusIndex=0;hovered=undefined; selected = undefined; index = 0; tab = 0;
407
462
  rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
408
463
  }
409
464
  if (s.leaseId) { leaseId = s.leaseId; synced = true; }
465
+ const revisionChanged=state?.revision!==s.revision;
410
466
  state = s; loading = false;
467
+ if(revisionChanged){hovered=undefined;upgradeHoverCache.clear();upgradeHoverToken++;}
468
+ focusIndex=Math.max(0,Math.min(focusIndex,regionItems(focusRegion).length-1));
469
+ if(!['action','controls'].includes(focusRegion)&&!regionItems(focusRegion).length)focusRegion='action';
470
+ if(modal?.upgradePicker && revisionChanged)void hoverUpgrade();
411
471
  if (modal?.validFor && !modal.validFor(s)) { modal=undefined; selected=undefined; index=0; note='奖励已失效,请重新选择。'; }
412
472
  if (!pending && (note.startsWith('已连接') || note.startsWith('连接中断'))) note = '';
413
473
  // The chained claim waits for the pick to be confirmed, then submits the exact
@@ -467,13 +527,26 @@ export function createScreen(ctx) {
467
527
  function choose(title, candidates, acceptChoice) {
468
528
  modal = { title, items: candidates, accept: acceptChoice, previousSelected: selected, previousIndex: index, previousPage: listPage }; listPage = 0; index = 0; selected = candidates[0]?.id; changed();
469
529
  }
530
+ const upgradeHoverCache=new Map();let upgradeHoverToken=0;
531
+ async function hoverUpgrade(){
532
+ if(!modal?.upgradePicker)return;
533
+ const row=items()[index];if(!row?.card)return;
534
+ const key=`${roomId}:${state.revision}:${row.id}`, token=++upgradeHoverToken, currentModal=modal;
535
+ const show=body=>{if(modal!==currentModal || items()[index]?.id!==row.id || token!==upgradeHoverToken)return;
536
+ modal.upgradeDetail=`升级前:${body.before.name} [${body.before.cost}] ${body.before.description}\n升级后:${body.after.name} [${body.after.cost}] ${body.after.description}`;changed();};
537
+ modal.upgradeDetail='正在读取升级预览…';changed();
538
+ if(upgradeHoverCache.has(key)){show(upgradeHoverCache.get(key));return;}
539
+ try{const body=await ctx.request(`${BASE}/${roomId}/previews/upgrade`,'POST',{cardId:row.id,source:'deck'});upgradeHoverCache.set(key,body);show(body);}
540
+ catch(error){if(modal===currentModal && token===upgradeHoverToken){modal.upgradeDetail=errorText(error);changed();}}
541
+ }
470
542
  function deckChoice(mode, callback) {
471
543
  const cards = state.self.deck.filter(c => mode === 'deckUpgrade' ? (!c.upgraded && !['status', 'curse', 'quest'].includes(c.kind)) : mode === 'deckEnchant' ? (c.canSown !== false && !c.sown && !c.inky && !c.enchantment && !['status', 'curse', 'quest'].includes(c.kind)) : c.removable !== false);
472
544
  if (!cards.length) { note = '没有符合条件的牌。'; changed(); return; }
473
545
  choose('选择牌组中的牌', cards.map(cardItem), row => {
474
- if (mode === 'deckUpgrade') { void previewUpgrade(row.id, 'deck', callback); return; }
546
+ if (mode === 'deckUpgrade') { callback(row.id); return; }
475
547
  callback(row.id);
476
548
  });
549
+ if(mode==='deckUpgrade'){modal.upgradePicker=true;void hoverUpgrade();}
477
550
  }
478
551
  async function previewUpgrade(cardId, source, confirmUpgrade) {
479
552
  if (!roomId || !canAct() || !cardId) return;
@@ -512,10 +585,88 @@ export function createScreen(ctx) {
512
585
  }
513
586
  // The keyboard branches and clicks move the same state through these, so the
514
587
  // two input paths cannot drift apart.
515
- function switchTab(next) { pileView=undefined; tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
516
- function moveTo(next) { const list = items(); index = Math.max(0, Math.min(Number.isFinite(next) ? next : 0, list.length - 1)); selected = list[index]?.id; changed(); }
588
+ const navKinds=new Set(['item','equipment','entry','entity','pile','endTurn','inspect','node','mapCenter','mapFull']);
589
+ function focusKey(hit){return `${hit.kind}:${hit.region??''}:${hit.id}`;}
590
+ function currentHit(){
591
+ if(focusRegion==='action')return hits.find(h=>mapNavigation()?h.kind==='node'&&h.id===(mapFocus??state.mapView.current):h.kind==='item'&&h.id===items()[index]?.id);
592
+ if(focusRegion==='units')return hits.find(h=>h.kind==='entity'&&String(h.id)===String(focusItem()?.id));
593
+ if(focusRegion==='entries')return hits.find(h=>h.kind==='entry'&&h.id===focusItem()?.id);
594
+ if(focusRegion==='controls')return hits.find(h=>focusKey(h)===focusControl);
595
+ return hits.find(h=>h.kind==='equipment'&&h.region===focusRegion&&h.index===focusIndex);
596
+ }
597
+ function selectHit(hit){
598
+ hovered=undefined;
599
+ if(hit.kind==='item'){focusRegion='action';moveTo(items().findIndex(row=>row.id===hit.id));}
600
+ else if(hit.kind==='node'){focusRegion='action';mapFocus=hit.id;mapAnchor=hit.id;mapScroll=undefined;}
601
+ else if(hit.kind==='equipment'){focusRegion=hit.region;focusIndex=hit.index;}
602
+ else if(hit.kind==='entity'){focusRegion='units';focusIndex=regionItems('units').findIndex(row=>String(row.id)===String(hit.id));}
603
+ else if(hit.kind==='entry'){focusRegion='entries';focusIndex=regionItems('entries').findIndex(row=>row.id===hit.id);}
604
+ else{focusRegion='controls';focusControl=focusKey(hit);}
605
+ changed();
606
+ }
607
+ function spatialMove(direction,skipLocal=false){
608
+ hovered=undefined;
609
+ screen.render(columns,height);
610
+ if(!skipLocal && focusRegion==='action' && !mapNavigation() && ['up','down'].includes(direction)){
611
+ const next=index+(direction==='up'?-1:1);
612
+ if(next>=0&&next<items().length){moveTo(next);return;}
613
+ }
614
+ if(['relics','potions'].includes(focusRegion)&&['left','right'].includes(direction)){
615
+ const next=focusIndex+(direction==='left'?-1:1);
616
+ if(next>=0&&next<regionItems(focusRegion).length){focusIndex=next;changed();return;}
617
+ }
618
+ if(focusRegion==='units'){
619
+ const enemies=state.enemies.filter(e=>e.hp>0||e.awaitingRevive),at=enemies.findIndex(e=>String(e.id)===String(focusItem()?.id));
620
+ const vertical=true;
621
+ if(at>=0 && (vertical?['up','down']:['left','right']).includes(direction)){
622
+ const next=at+(['up','left'].includes(direction)?-1:1);
623
+ if(next>=0&&next<enemies.length){focusIndex=regionItems('units').findIndex(row=>String(row.id)===String(enemies[next].id));changed();return;}
624
+ }
625
+ }
626
+ const current=currentHit()??hits.find(h=>navKinds.has(h.kind));
627
+ if(!current)return;
628
+ const point=h=>({x:h.col+Math.min(Number.isFinite(h.width)?h.width:columns,24)/2,y:h.row+(h.height-1)/2});
629
+ const origin=point(current),vertical=['up','down'].includes(direction),sign=['up','left'].includes(direction)?-1:1;
630
+ let candidates=hits.filter(h=>navKinds.has(h.kind)&&h!==current&&!(h.kind==='item'&&items().find(r=>r.id===h.id)?.separator));
631
+ if(focusRegion==='action' && (skipLocal||['up','down'].includes(direction)))candidates=candidates.filter(h=>h.kind!==(mapNavigation()?'node':'item'));
632
+ const score=h=>{const p=point(h),forward=sign*(vertical?p.y-origin.y:p.x-origin.x),cross=Math.abs(vertical?p.x-origin.x:p.y-origin.y);return {h,forward,cross,score:forward+cross*(vertical?1:100)};};
633
+ const choices=candidates.map(score).filter(p=>p.forward>0).sort((a,b)=>a.score-b.score);
634
+ if(choices.length){selectHit(choices[0].h);return;}
635
+ // A boundary wraps to the opposite edge, keeping every region reachable.
636
+ const wrap=candidates.map(score).sort((a,b)=>a.forward-b.forward||a.cross-b.cross);
637
+ if(wrap.length)selectHit(wrap[0].h);
638
+ }
639
+ function showFocusedDetail(){
640
+ const text=detailText(),title=focusItem()?.name??'详情';
641
+ choose(title,wrapText(text,Math.max(1,columns-4)).map((label,i)=>({id:String(i),label})),()=>changed());
642
+ }
643
+ function activateFocus(){
644
+ if(focusRegion==='action'){confirm();return;}
645
+ const row=focusItem(),hit=currentHit();
646
+ if(focusRegion==='potions')potionMenu(row);
647
+ else if(focusRegion==='entries'){if(row?.id==='deck')openPile('deck');else void openLog();}
648
+ else if(focusRegion==='units'||focusRegion==='relics')showFocusedDetail();
649
+ else if(hit?.kind==='pile')openPile(hit.id);
650
+ else if(hit?.kind==='endTurn')endTurn();
651
+ else if(hit?.kind==='inspect')void inspectPlayer(Number(hit.id));
652
+ else if(hit?.kind==='mapCenter'){mapScroll=undefined;mapAnchor=undefined;mapFocus=undefined;mapFull=false;changed();}
653
+ else if(hit?.kind==='mapFull'){mapFull=!mapFull;mapScroll=undefined;changed();}
654
+ }
655
+ function paintFocus(frame){
656
+ if(modal||targeting||!state)return;
657
+ const hit=currentHit();if(!hit||!frame[hit.row])return;
658
+ if(focusRegion==='action')return;
659
+ const text=frame[hit.row].text;let col=0,start=0,end=text.length;
660
+ for(const char of text){const next=col+displayWidth(char);if(next<=hit.col)start+=char.length;if(next>hit.col+Math.min(hit.width,columns)){end=start;break;}col=next;}
661
+ // Highlight only the focused control, not its neighbours on the same row.
662
+ let offset=0;col=0;end=text.length;
663
+ for(const char of text){if(col>=hit.col+Math.min(hit.width,columns)){end=offset;break;}col+=displayWidth(char);offset+=char.length;}
664
+ frame[hit.row].text=text.slice(0,start)+'\x1b[7m'+text.slice(start,end)+'\x1b[27m'+text.slice(end);
665
+ }
666
+ function moveTo(next) { const list = items(); index = Math.max(0, Math.min(Number.isFinite(next) ? next : 0, list.length - 1)); selected = list[index]?.id; changed();if(modal?.upgradePicker)void hoverUpgrade(); }
517
667
  function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
518
668
  function clickRow(id) {
669
+ focusRegion='action';hovered=undefined;
519
670
  const list = items(), at = list.findIndex(row => row.id === id);
520
671
  // A pushed state can replace the list under a click: re-anchor and never let
521
672
  // the cursor clamp confirm a row the player never saw.
@@ -524,71 +675,23 @@ export function createScreen(ctx) {
524
675
  confirm();
525
676
  }
526
677
  function intentLines(v, enemy, state) {
527
- const intents = [];
528
- if (!v) return intents;
529
- if (v.damage) intents.push(`攻击 ${v.damageByTarget?.[ctx.user.id] ?? v.damage}×${v.hits ?? 1}(对我;全队受击)`);
530
- if (v.strength) intents.push(`力量 +${v.strength}`);
531
- if (v.vigor) intents.push(`下一次攻击伤害 +${v.vigor}`);
532
- if (v.vitalSpark) intents.push(`污染强度 +${v.vitalSpark}`);
533
- if (v.ritual) intents.push(`仪式 +${v.ritual}(从下次行动结束起获得力量)`);
534
- if (v.block) intents.push(`格挡 +${v.block}`);
535
- if (v.moveBlock) intents.push(`格挡基础值 +${v.moveBlock}`);
536
- if (v.dexterity) intents.push(`敏捷 +${v.dexterity}`);
537
- if (v.status) {
538
- const pile = v.statusPile === 'hand' ? '手中' : v.statusPile === 'draw' ? '抽牌堆' : '弃牌堆';
539
- intents.push(`加入 ${v.count + (v.statusExtra ?? 0)} 张状态牌(${pile})`);
540
- } else if (v.statusExtra) intents.push(`额外加入 ${v.statusExtra} 张状态牌`);
541
- for (const [key,amount] of Object.entries(v.debuffs ?? {})) intents.push(`${powerNames[key] ?? key} ${amount < 0 ? '持续至施加者死亡' : amount}`);
542
- if (v.knowledge) intents.push('知识诅咒:每人选择一种代价');
543
- if (v.healPerPlayer) intents.push(`恢复 ${v.healPerPlayer * state.players.length} HP`);
544
- if (v.stunned) intents.push('眩晕:跳过本次行动');
545
- if (v.sleep) intents.push('沉睡');
546
- if (v.revive) intents.push('复活');
547
- if (v.reviveIllusion) intents.push('幻象恢复全部生命');
548
- if (v.summonEnemy) intents.push(`召唤 ${v.summonEnemy}`);
549
- if (v.bloat) intents.push('召唤一枚气体炸弹');
550
- if (v.ratBackup) intents.push('召唤一只双尾鼠');
551
- if (v.stealGold) intents.push(`窃取每人至多 ${v.stealGold} 金币`);
552
- if (v.wakeGremlin) intents.push('起身');
553
- if (v.escapeEnemy) intents.push('逃跑');
554
- if (v.soar) intents.push('飞翔:攻击承伤减半');
555
- if (v.land) intents.push('落地:结束飞翔');
556
- if (v.fabricate) intents.push(v.fabricate === 2 ? '制造防御与攻击机器人各一台' : '制造一台攻击机器人');
557
- if (v.guardFabricator) intents.push(`制造者格挡 +${v.guardFabricator}`);
558
- if (v.layEggs) intents.push(`产下至多 ${v.layEggs} 枚蛋`);
559
- if (v.hatchEgg) intents.push('孵化:清除仆从以外状态并恢复幼体生命');
560
- if (v.stealCard) intents.push('窃取抽牌堆或弃牌堆的一张原始牌');
561
- if (v.flutter) intents.push(`振翅 ${v.flutter}(按人数缩放)`);
562
- if (enemy.stolenCards?.length) intents.push(`窃走你的牌:${enemy.stolenCards.map(c => c.name).join('、')}`);
563
- if (v.teamStrength) intents.push(`全体敌人力量 +${v.teamStrength}`);
564
- if (v.reattach) intents.push('重连:其他体节存活则恢复生命');
565
- if (v.liquify) intents.push('流沙倒计时 4;每人抽牌堆、弃牌堆各加入 3 张拼命逃脱');
566
- if (v.hiveSpit) intents.push('强化蜂巢与力量;蜂巢达到 3 层后改为力量 +2');
567
- if (v.thornsBefore) intents.push(`反伤 ${signed(v.thornsBefore)}`);
568
- if (v.thornsAfter) intents.push(`反伤 ${signed(v.thornsAfter)}`);
569
- if (v.plow) intents.push(`冲锋:生命降至 ${v.plow} 后转阶段`);
570
- if (v.selfKill) intents.push('自爆:行动后自身死亡');
571
- if (v.intangible) intents.push(`无实体 ${v.intangible}`);
572
- if (v.beckon) intents.push('塞入 2 张「召唤」(抽牌堆与弃牌堆各 1 张)');
573
- if (v.burrowed) intents.push(`钻地 ${v.burrowed}:保留格挡,破盾后眩晕`);
574
- if (v.allyStrength) intents.push(`友军力量 +${v.allyStrength}`);
575
- if (v.steam) intents.push(`蒸汽压力 +${v.steam}`);
576
- for (const [key, value] of Object.entries(v)) {
577
- if (renderedIntentKeys.includes(key) || key in internalIntentKeys) continue;
578
- // A field the rules just added is shown as the raw field, never dropped.
579
- intents.push(`原始字段 ${key}=${JSON.stringify(value)}`);
580
- }
581
- return intents;
678
+ const symbols={attack:'⚔',defend:'⛨',buff:'↑',debuff:'↓',status:'↓',heal:'♥',summon:'⊕',sleep:'zZ',stun:'…',escape:'↗',unknown:'?',deathBlow:'☠'};
679
+ const names={attack:'攻击',defend:'防御',buff:'Buff',debuff:'DeBuff',status:'状态牌',heal:'治疗',summon:'召唤',sleep:'沉睡',stun:'眩晕',escape:'逃跑',unknown:'未知',deathBlow:'致命攻击'};
680
+ const declared=enemy.intentDisplay ?? (v?.damage?[{type:'attack',damage:v.damageByTarget?.[ctx.user.id]??v.damage,hits:v.hits??1}]:[{type:v?.sleep?'sleep':'unknown'}]);
681
+ return declared.filter(part=>part.type!=='hidden').map(part=>`${symbols[part.type]??'?'} ${names[part.type]??'未知'}${part.damage!==undefined?` ${part.damage}${(part.hits??1)>1?`×${part.hits}`:''}`:''}${part.count!==undefined?` ${part.count}`:''}`);
582
682
  }
683
+
583
684
  function hoverRow(id) {
685
+ focusRegion='action';
584
686
  const at = items().findIndex(row => row.id === id);
585
687
  if (at >= 0) moveTo(at);
586
688
  }
587
- function tabSpans(labels) { let from = 0; return labels.map((label, index) => { const span = { tab: index, from, to: from + displayWidth(label) }; from = span.to + 3; return span; }); }
588
689
  // --- battlefield ------------------------------------------------------------
589
690
  const BAR_CELLS = 10;
590
- const MIN_BLOCK = 18;
591
- const wideBattlefield = () => columns >= 100 && height >= 30;
691
+ let battleLayout='columns';
692
+ const canSwitchLayout=()=>columns>=68&&height>=24;
693
+ const sideBySide=()=>canSwitchLayout()&&battleLayout==='columns';
694
+ function toggleLayout(){if(!canSwitchLayout())return;battleLayout=battleLayout==='columns'?'stacked':'columns';changed();}
592
695
  function healthBar(current, maximum) {
593
696
  const ratio = maximum > 0 ? Math.max(0, Math.min(1, current / maximum)) : 0;
594
697
  const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
@@ -637,27 +740,6 @@ export function createScreen(ctx) {
637
740
  if (line) lines.push(line);
638
741
  return lines.length ? lines : [''];
639
742
  }
640
- function statLines(creature, extras = [], width) {
641
- const entries = powerEntries(creature);
642
- const buffs = entries.filter(entry => !debuffPowers.has(entry.key)).map(entry => entry.text);
643
- const debuffs = entries.filter(entry => debuffPowers.has(entry.key)).map(entry => entry.text);
644
- const wrap = (value, prefix) => {
645
- if (!width) return [prefix + value];
646
- return wrapText(prefix + value, width).map((line, index) => index ? ` ${line}` : line);
647
- };
648
- return [`格挡 ${creature.block} ${healthBar(creature.hp, creature.maxHp)}`, `${hpText(creature)} HP`,
649
- ...wrap(buffs.join(' · ') || '无', '增益:'), ...wrap(debuffs.join(' · ') || '无', '减益:'),
650
- ...extras.flatMap(value => width ? wrapText(value, width) : [value])];
651
- }
652
- function enemyBlock(enemy, width) {
653
- const intents = intentLines(enemy.intent, enemy, state);
654
- const head = intents.length
655
- ? (width ? wrapText(intents.join(' · '), width) : [intents.join(' · ')])
656
- : ['(本回合无行动)'];
657
- return [...head, enemy.name, ...statLines(enemy, [], width)];
658
- }
659
- // Narrow screens stack the two sides as sections; one line per creature keeps
660
- // every number and both power lists visible instead of shrinking blocks.
661
743
  function compactLines(creature, arena, width, room = 1) {
662
744
  const head = arena === 'ally'
663
745
  ? `${creature.id === ctx.user.id ? '› ' : ''}${creature.name}${creature.characterName ? ` · ${creature.characterName}` : ''}${creature.done ? ' · 已结束' : ''}`
@@ -667,8 +749,8 @@ export function createScreen(ctx) {
667
749
  const buffs = entries.filter(entry => !debuffPowers.has(entry.key)).map(entry => entry.text);
668
750
  const debuffs = entries.filter(entry => debuffPowers.has(entry.key)).map(entry => entry.text);
669
751
  const resources = arena === 'ally' ? resourcesText(creature) : '';
670
- const text = [head, `${hpText(creature)} HP · 格挡 ${creature.block}`, intent,
671
- `增益:${buffs.join(' · ') || '无'}`, `减益:${debuffs.join(' · ') || '无'}`, resources]
752
+ const text = [head, `${hpText(creature)} HP · ${creature.block}`, intent,
753
+ `Buff:${buffs.join(' · ') || '无'}`, `DeBuff:${debuffs.join(' · ') || '无'}`, resources]
672
754
  .filter(Boolean).join(' · ');
673
755
  if (!width) return [text];
674
756
  // Two lines at most: a tight frame keeps one cut row per creature so nobody
@@ -857,6 +939,7 @@ function mapHeader(width, view) {
857
939
  }
858
940
  function sphereMove(dx, dy) {
859
941
  const view = sphereView(); if (!view) return;
942
+ if((dx<0&&sphereCursor.x===0)||(dx>0&&sphereCursor.x===view.width-1)||(dy<0&&sphereCursor.y===0)||(dy>0&&sphereCursor.y===view.height-1)){spatialMove(dx<0?'left':dx>0?'right':dy<0?'up':'down',true);return;}
860
943
  sphereCursor = { x: Math.max(0, Math.min(view.width - 1, sphereCursor.x + dx)),
861
944
  y: Math.max(0, Math.min(view.height - 1, sphereCursor.y + dy)) };
862
945
  changed();
@@ -872,7 +955,7 @@ function mapHeader(width, view) {
872
955
  // Teammate detail. Read only, and paged on the server: the client asks for a
873
956
  // page and shows what comes back, so it never holds a second copy of anyone's
874
957
  // deck and never has to keep one in step.
875
- async function inspectPlayer(id, page = 0) {
958
+ async function inspectPlayer(id, page = 0, atEnd=false) {
876
959
  if (!roomId || !canAct()) return;
877
960
  await guard(async () => {
878
961
  const body = await ctx.request(`${BASE}/${roomId}/players/${id}/inspection?page=${page}`);
@@ -886,6 +969,7 @@ function mapHeader(width, view) {
886
969
  () => { anchor(); changed(); });
887
970
  if(previous)Object.assign(modal,previous);
888
971
  modal.inspection={id,page,pages,total:body.total};
972
+ if(atEnd)moveTo(modal.items.length-1);
889
973
  changed();
890
974
  });
891
975
  }
@@ -903,18 +987,18 @@ function mapHeader(width, view) {
903
987
  }
904
988
  function endTurnLabel() {
905
989
  const own = me();
906
- if ((own?.hp ?? 0) <= 0) return '[已倒下]';
990
+ if ((own?.hp ?? 0) <= 0) return `[已倒下 ${state.turn}]`;
907
991
  if (state?.phase !== 'combat') return '';
908
992
  const control = endTurnState();
909
- if (control.canUndoEndTurn) return '[E 撤销结束回合]';
910
- if (control.canEndTurn) return state.paused ? '[等待继续]' : '[E 结束回合]';
911
- return state.paused ? '[等待继续]' : `[${control.reason || '等待结算'}]`;
993
+ if (control.canUndoEndTurn) return `[E 撤销结束回合 ${state.turn}]`;
994
+ if (control.canEndTurn) return state.paused ? `[等待继续 ${state.turn}]` : `[E 结束回合 ${state.turn}]`;
995
+ return state.paused ? `[等待继续 ${state.turn}]` : `[${control.reason || '等待结算'} ${state.turn}]`;
912
996
  }
913
997
  function inspectEntity(id) {
914
998
  const enemy = (state.enemies ?? []).find(entry => entry.id === id);
915
999
  const player = (state.players ?? []).find(entry => String(entry.id) === id);
916
- if (enemy) choose(`${enemy.name} · Esc 返回`, [intentLines(enemy.intent, enemy, state).join(' · ') || '(本回合无行动)', `${hpText(enemy)} HP · 格挡 ${enemy.block} · ${powersText(enemy.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
917
- else if (player) choose(`${player.name} · Esc 返回`, [`${hpText(player)} HP · 格挡 ${player.block} · ${powersText(player.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
1000
+ if (enemy) choose(`${enemy.name} · Esc 返回`, [intentLines(enemy.intent, enemy, state).join(' · ') || '(本回合无行动)', `${hpText(enemy)} HP · ${enemy.block} · ${powersText(enemy.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
1001
+ else if (player) choose(`${player.name} · Esc 返回`, [`${hpText(player)} HP · ${player.block} · ${powersText(player.powers)}`].map((label, index) => ({ id: `detail${index}`, label })), () => { anchor(); changed(); });
918
1002
  }
919
1003
  // §C: a legal target is marked by colour — enemies red, allies blue — and the
920
1004
  // one being aimed at is the brighter shade.
@@ -955,8 +1039,8 @@ function mapHeader(width, view) {
955
1039
  // there, so the list stays the surface for that case alone.
956
1040
  if (state?.phase !== 'combat') {
957
1041
  const rows = arena === 'enemy'
958
- ? (state?.enemies ?? []).filter(e => e.hp > 0).map(e => ({ id: e.id, label: `${e.name} · ${hpText(e)} HP · 格挡 ${e.block}` }))
959
- : (state?.players ?? []).filter(p => p.hp > 0 && p.id !== ctx.user.id).map(p => ({ id: String(p.id), playerId: p.id, label: `${p.name} · ${p.hp}/${p.maxHp} HP · 格挡 ${p.block}` }));
1042
+ ? (state?.enemies ?? []).filter(e => e.hp > 0).map(e => ({ id: e.id, label: `${e.name} · ${hpText(e)} HP · ${e.block}` }))
1043
+ : (state?.players ?? []).filter(p => p.hp > 0 && p.id !== ctx.user.id).map(p => ({ id: String(p.id), playerId: p.id, label: `${p.name} · ${p.hp}/${p.maxHp} HP · ${p.block}` }));
960
1044
  if (!rows.length) { note = arena === 'enemy' ? '没有可攻击的敌人。' : '没有存活的其他队友。'; changed(); return; }
961
1045
  choose(title, rows, row => void submit({ ...payloadOf(action), target: arena === 'ally' ? row.playerId : row.id }));
962
1046
  return;
@@ -979,7 +1063,7 @@ function mapHeader(width, view) {
979
1063
  void submit({ ...payloadOf(action), target: arena === 'ally' ? Number(id) : id });
980
1064
  }
981
1065
  function confirm() {
982
- if (busy || pending) return;
1066
+ if (busy || pending || modal?.battleLog) return;
983
1067
  anchor(); const row = items()[index]; if (!row) return;
984
1068
  if (modal) { const current = modal; modal = undefined; selected = current.previousSelected; index = current.previousIndex; listPage = current.previousPage ?? 0; current.accept(row); return; }
985
1069
  if (!roomId) {
@@ -1007,16 +1091,6 @@ function mapHeader(width, view) {
1007
1091
  changed();return;
1008
1092
  }
1009
1093
  if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
1010
- if (activeTab() === 'potions' && row.potion) {
1011
- if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
1012
- if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
1013
- if (state.paused || (state.phase === 'combat' ? me()?.done : row.potion.usage !== 'AnyTime')) { note = '当前不能使用这瓶药水。'; changed(); return; }
1014
- const action = { type: 'potion', potion: row.id, phaseId: state.phaseId };
1015
- // §C: a potion with a single target is aimed at the same region as a card.
1016
- if (row.potion.target === 'AnyEnemy' || row.potion.key === 'FirePotion') beginTargeting({ ...action, label: row.potion.name ?? '药水' }, 'enemy', '选择药水目标');
1017
- else if (row.potion.target === 'AnyPlayer' && state.players.filter(p => p.hp > 0).length > 1) beginTargeting({ ...action, label: row.potion.name ?? '药水' }, 'ally', '选择药水目标');
1018
- else void submit(action); return;
1019
- }
1020
1094
  if (tab === 0 && row.group && row.id === row.group) { if (row.open) rewardPick.delete(row.group); rewardOpen = row.open ? undefined : row.group; changed(); return; }
1021
1095
  if (tab === 0 && row.candidate && row.action) {
1022
1096
  // Picking a card reward's card is the deliberate claim.
@@ -1134,7 +1208,7 @@ function mapHeader(width, view) {
1134
1208
  // The drawing reads the view state and the party; this supplies both.
1135
1209
  const mapLines = (view, width) => drawMap(view, width,
1136
1210
  { full: mapFull, scroll: mapScroll, anchor: mapAnchor, focus:mapFocus, height:mapBudget, players: state?.players, options: state?.options });
1137
- const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
1211
+ const mapNavigation = () => !modal && !targeting && !pileView && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
1138
1212
  const screen = {
1139
1213
  render(width, rows) {
1140
1214
  columns = width; height = rows; hits = [];
@@ -1143,8 +1217,37 @@ function mapHeader(width, view) {
1143
1217
  if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
1144
1218
  const own = me();
1145
1219
  const heading = needsCharacterChoice() ? '选择初始角色' : state ? `A${state.ascension ?? 0} · ${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
1146
- const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
1220
+ const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${state.self.potions.length}/${state.self.potionCapacity ?? 3}` : '';
1147
1221
  add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
1222
+ if(state){
1223
+ const entries=regionItems('entries'), labels=entries.map((row,i)=>`${focusRegion==='entries'&&focusIndex===i?'› ':''}${row.name}`);
1224
+ const right=Math.max(0,width-displayWidth(labels[0]));
1225
+ mark({kind:'entry',id:'log',col:0,width:displayWidth(labels[1])});
1226
+ mark({kind:'entry',id:'deck',col:right,width:displayWidth(labels[0])});
1227
+ add(pad(labels[1],right)+labels[0],'muted');
1228
+ for(const region of ['relics','potions']){
1229
+ const list=regionItems(region);let from=Math.min(equipmentOffset[region],Math.max(0,list.length-1));
1230
+ const wanted=focusRegion===region?Math.max(0,Math.min(focusIndex,list.length-1)):from;
1231
+ if(wanted<from)from=wanted;
1232
+ const label=row=>`${row.name}${row.counter!==undefined?`(${row.counter})`:''}`;
1233
+ while(from<wanted && displayWidth(list.slice(from,wanted+1).map(label).join(' · '))>width-14)from++;
1234
+ equipmentOffset[region]=Math.max(0,from);
1235
+ let text=(region==='relics'?'遗物':'药水')+' ';let shown=0;
1236
+ for(let i=from;i<list.length;i++){
1237
+ const part=cut(`${focusRegion===region&&focusIndex===i?'›':''}${label(list[i])}`,Math.max(4,width-14));
1238
+ if(displayWidth(text+' '+part)>width-5)break;
1239
+ const col=displayWidth(text);mark({kind:'equipment',region,id:list[i].id,index:i,col,width:displayWidth(part)});
1240
+ text+=part+' · ';shown++;
1241
+ }
1242
+ if(!list.length)text+='无';
1243
+ if(from>0||from+shown<list.length){
1244
+ mark({kind:'equipmentPage',region,delta:-1,col:width-5,width:2});
1245
+ mark({kind:'equipmentPage',region,delta:1,col:width-2,width:2});
1246
+ text=pad(cut(text,width-5),width-5)+'‹ ›';
1247
+ }
1248
+ add(cut(text,width),'muted');
1249
+ }
1250
+ }
1148
1251
  if(mapNavigation()) {
1149
1252
  const view=state.mapView, head=mapHeader(width,view), headerRow=frame.length;
1150
1253
  add(head.text,'muted');
@@ -1157,45 +1260,42 @@ function mapHeader(width, view) {
1157
1260
  }
1158
1261
  while(frame.length<rows-5)add('');
1159
1262
  add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
1160
- add(cut(mapDetailText(view,mapData(view)),width),'muted');
1263
+ add(cut(focusRegion==='action'&&!hovered?mapDetailText(view,mapData(view)):detailText(),width),'muted');
1161
1264
  add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
1162
1265
  add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
1163
- mark({kind:'tab',advance:true});
1164
- add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
1266
+ add(cut('方向键焦点 · D 牌组 · V 日志 · M 关闭地图',width),'muted');
1165
1267
  hits=hits.filter(hit=>hit.row<rows);
1166
- return frame.slice(0,rows);
1268
+ paintFocus(frame);return frame.slice(0,rows);
1167
1269
  }
1168
1270
  if (unsupported) add(`此扩展版本不认识该阶段「${unsupported}」,请在更新扩展后继续(当前只读)。`, 'error');
1169
1271
  if (targeting) add(cut(`正在使用:${targeting.label} · 点击目标或 ←/→ 选择 · Enter 确认 · Esc 取消`, width), 'selected');
1170
1272
  if (state) {
1171
1273
  if (state.phase === 'combat') {
1172
- add(`回合 ${state.turn}${own?.done ? ' · 你已结束回合' : ' · 你的回合'}`, 'muted');
1173
1274
  const alive = state.enemies.filter(e => e.hp > 0 || e.awaitingRevive);
1174
- const chosen = targetIndex();
1275
+ const chosen = focusRegion==='units'&&!targeting?alive.findIndex(e=>String(e.id)===String(focusItem()?.id)):targetIndex();
1175
1276
  const party = [own, ...state.players.filter(p => p.id !== ctx.user.id)];
1176
- const partyBlocks = party.map(p => allyBlock(p));
1177
1277
  const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
1178
1278
  const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
1179
- if(width>=68 && rows>=24 && !wideBattlefield()) {
1279
+ if(sideBySide()) {
1180
1280
  // Medium terminals keep the two teams side by side. Party members
1181
1281
  // stack in the left column; enemies page independently on the right.
1182
1282
  const leftWidth=Math.floor((width-3)/2),rightWidth=width-leftWidth-3;
1183
- const budget=Math.max(4,rows-frame.length-10);
1184
- const room=Math.max(2,Math.min(5,Math.floor(budget/party.length)));
1283
+ const budget=Math.max(4,rows-frame.length-13);
1284
+ const room=Math.max(2,Math.min(7,Math.floor(budget/party.length)));
1185
1285
  const perPage=Math.max(1,Math.floor(budget/room));
1186
1286
  const pages=Math.max(1,Math.ceil(alive.length/perPage));
1187
1287
  if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
1188
1288
  enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
1189
1289
  const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
1190
1290
  const compactBlock=(creature,arena,span)=>{
1191
- const name=`${arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
1192
- const health=`${hpText(creature)} HP · 格挡 ${creature.block}`;
1291
+ const name=`${focusRegion==='units'&&String(focusItem()?.id)===String(creature.id)?'› ':arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
1292
+ const health=`${hpText(creature)} HP · ${creature.block}`;
1193
1293
  const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
1194
1294
  const powers=powerEntries(creature).map(p=>p.text).join(' · ');
1195
1295
  const body=[name,health];
1196
1296
  if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
1197
1297
  if(room>=3)body.push(intent||powers||'无额外状态');
1198
- if(room>=5)body.push(intent?powers:'');
1298
+ if(room>=6){const entries=powerEntries(creature),buffs=entries.filter(e=>!debuffPowers.has(e.key)).map(e=>e.text),debuffs=entries.filter(e=>debuffPowers.has(e.key)).map(e=>e.text);body.push(`Buff:${buffs.join(' · ')||'无'}`,`DeBuff:${debuffs.join(' · ')||'无'}`);}else if(room>=5)body.push(intent?powers:'');
1199
1299
  return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
1200
1300
  };
1201
1301
  const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
@@ -1209,40 +1309,7 @@ function mapHeader(width, view) {
1209
1309
  hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
1210
1310
  }));
1211
1311
  for(let i=0;i<Math.max(...rendered.map(c=>c.length));i++)add(pad(rendered[0][i]??'',leftWidth)+' │ '+(rendered[1][i]??''));
1212
- if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · ←/→ 或点击翻页`,'muted');}
1213
- } else if (wideBattlefield()) {
1214
- // The party column is as wide as what it prints (a fixed third used to
1215
- // cut 增益/减益 short); the enemies share what is left.
1216
- const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
1217
- let allyWidth = Math.max(MIN_BLOCK, Math.min(Math.floor(width * 0.42), natural + 2));
1218
- const perPage = Math.max(1, Math.floor((width + 2 - partyBlocks.length * (allyWidth + 2)) / (MIN_BLOCK + 2)));
1219
- const pages = Math.max(1, Math.ceil(alive.length / perPage));
1220
- if (chosen >= 0) enemyPage = Math.floor(chosen / perPage);
1221
- enemyPage = Math.max(0, Math.min(pages - 1, enemyPage ?? 0));
1222
- const shown = alive.slice(enemyPage * perPage, enemyPage * perPage + perPage);
1223
- const gaps = 2 * Math.max(0, partyBlocks.length + shown.length - 1);
1224
- if (shown.length && width - gaps - allyWidth * partyBlocks.length < MIN_BLOCK * shown.length) {
1225
- allyWidth = Math.max(MIN_BLOCK, Math.floor((width - gaps - MIN_BLOCK * shown.length) / Math.max(1, partyBlocks.length)));
1226
- }
1227
- const foeWidth = shown.length ? Math.max(MIN_BLOCK, Math.floor((width - gaps - allyWidth * partyBlocks.length) / shown.length)) : 0;
1228
- const widths = [...party.map(() => allyWidth), ...shown.map(() => foeWidth)];
1229
- const blocks = [
1230
- // §B: the party block keeps the enemy block's row count so names, bars
1231
- // and power lines sit on the same rows across the battlefield.
1232
- ...party.map(player => ({ id: String(player?.id), lines: ['', ...allyBlock(player, allyWidth - 2)], legal: legalAlly(String(player?.id)) })),
1233
- ...shown.map(enemy => ({ id: enemy.id, lines: enemyBlock(enemy, foeWidth - 2), legal: legalEnemy(enemy) })),
1234
- ];
1235
- const body = blocks.map(entry => entry.legal ? tint(entry.lines, targetTint(entry, targeting.arena), aimed(entry)) : entry.lines);
1236
- const tall = Math.max(...body.map(lines => lines.length));
1237
- const startRow = frame.length;
1238
- for (let line = 0; line < tall; line++) add(body.map((lines, index) => pad(cut(lines[line] ?? '', widths[index] - 2), widths[index])).join(' '), 'text');
1239
- blocks.forEach((entry, index) => hits.push({
1240
- kind: 'entity', id: entry.id,
1241
- row: startRow, col: widths.slice(0, index).reduce((total, value) => total + value + 2, 0),
1242
- width: widths[index], height: tall,
1243
- }));
1244
- if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · PgUp/PgDn 翻页`, 'muted');
1245
- add('─'.repeat(Math.max(0, width)), 'muted');
1312
+ if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · 方向键选择 · 自动翻页`,'muted');}
1246
1313
  } else {
1247
1314
  const budget = Math.max(2, rows - frame.length - 12);
1248
1315
  const room = budget >= party.length * 2 ? 2 : 1;
@@ -1264,7 +1331,7 @@ function mapHeader(width, view) {
1264
1331
  for (const line of shownLines) add(line, 'text');
1265
1332
  hits.push({ kind: 'entity', id: entry.id, row: top, col: 0, width, height: shownLines.length });
1266
1333
  }
1267
- if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · PgUp/PgDn 翻页`, 'muted');
1334
+ if (pages > 1) add(`敌人 ${enemyPage + 1}/${pages} 页 · 共 ${alive.length} 名 · 方向键继续浏览`, 'muted');
1268
1335
  }
1269
1336
  const usable = own?.energy ?? 0;
1270
1337
  const capacity = own?.energyCapacity ?? usable;
@@ -1277,8 +1344,8 @@ function mapHeader(width, view) {
1277
1344
  if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
1278
1345
  add(pad(energy, buttonAt) + button, 'title');
1279
1346
  const piles=[['draw','A','抽牌堆',state.self.draw?.length??state.self.drawCount??0],
1280
- ['exhaust','S','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
1281
- ['discard','D','弃牌堆',state.self.discard.length]];
1347
+ ['exhaust','X','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
1348
+ ['discard','S','弃牌堆',state.self.discard.length]];
1282
1349
  const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
1283
1350
  const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
1284
1351
  if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
@@ -1294,7 +1361,7 @@ function mapHeader(width, view) {
1294
1361
  // Clicking a teammate is the same act as pressing P on them. The
1295
1362
  // mark is taken before the line is pushed, while the row still is
1296
1363
  // the one it will land on.
1297
- if (player.id !== ctx.user.id && !modal) mark({ kind: 'inspect', id: String(player.id), width: displayWidth(rowText) });
1364
+ if (!modal) mark({ kind: 'entity', id: String(player.id), width: displayWidth(rowText) });
1298
1365
  add(rowText, player.id === ctx.user.id ? 'selected' : 'text');
1299
1366
  }
1300
1367
  if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
@@ -1308,44 +1375,32 @@ function mapHeader(width, view) {
1308
1375
  }
1309
1376
 
1310
1377
  }
1311
- const labels = tabs.map((key, i) => {
1312
- const count = ['deck', 'draw', 'discard', 'exhaust'].includes(key) ? state.self[key]?.length ?? (key === 'draw' ? state.self.drawCount : key === 'exhaust' ? state.self.exhaustCount : 0) : undefined;
1313
- const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
1314
- return !pileView && i === tab ? `[${label}]` : label;
1315
- });
1316
1378
  const decision = state.pendingChoice ?? state.pendingDecision;
1317
- if(modal || (tab===0 && decision)) {
1318
- add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
1319
- }else {
1320
- let text='',spans=[];
1321
- const flush=()=>{mark({kind:'tab',spans});add(text,'title');text='';spans=[];};
1322
- for(let i=0;i<labels.length;i++){
1323
- const label=labels[i];
1324
- if(text && displayWidth(text+' · '+label)>width)flush();
1325
- const from=displayWidth(text)+(text?3:0);
1326
- text+=(text?' · ':'')+label;spans.push({tab:i,from,to:from+displayWidth(label)});
1327
- }
1328
- if(text)flush();
1329
- if(pileView)add(`${tabNames[pileView]} · Esc 返回 · Tab 切换面板`,'title');
1330
- }
1379
+ if(modal || (tab===0 && decision))add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
1380
+ else if(pileView)add(`${tabNames[pileView]} · Esc 返回 · U 升级预览`,'title');
1331
1381
  if (modal?.detail) {
1332
1382
  const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
1333
1383
  const count = Math.max(1, Math.min(6, rows - frame.length - 9));
1334
1384
  modal.detailOffset = Math.min(modal.detailOffset ?? 0, Math.max(0, lines.length - count));
1335
1385
  mark({ kind: 'detail' });
1336
1386
  for (const line of lines.slice(modal.detailOffset, modal.detailOffset + count)) add(line);
1337
- if (lines.length > count) add('PgUp/PgDn 查看完整说明', 'muted');
1387
+ if (lines.length > count) add('↑↓ 浏览 · 到边缘继续滚动说明', 'muted');
1338
1388
  }
1339
1389
  } else add('频道成员可加入 · R 刷新房间列表', 'muted');
1340
1390
  if (loading) add('正在加载…', 'muted');
1341
- const footerRows=activeTab()==='potions' && !modal ? 2 : 1;
1342
- const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - footerRows - 3)) : 0;
1391
+ const detailRows=state && (!modal || modal.upgradePicker) ? 3 : 0;
1392
+ const footerRows=1+detailRows;
1343
1393
  if (state && state.phase!=='combat') add(''); // breathing room above the list
1344
- anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1 - (logLines ? logLines + 1 : 0));
1394
+ anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1);
1345
1395
  pageSize = available;
1396
+ listPage=Math.floor(Math.max(0,index)/available);
1346
1397
  const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
1347
1398
  listPage = Math.max(0, Math.min(maxPage, listPage));
1348
1399
  const start = listPage * available;
1400
+ const cards=list.filter(row=>row.card).map(row=>row.card);
1401
+ const cardNameWidth=Math.min(Math.max(4,...cards.map(card=>displayWidth(card.name))),Math.max(8,Math.floor(width*.4)));
1402
+ const cardCostWidth=Math.max(3,...cards.map(card=>displayWidth(`[${card.cost}]`)));
1403
+ const rowLabel=row=>row.card?`${pad(cut(row.card.name,cardNameWidth),cardNameWidth)} ${pad(`[${row.card.cost}]`,cardCostWidth)} ${row.card.description}`:row.label;
1349
1404
  if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
1350
1405
  // §E: a wide frame puts the shop's detail beside the goods; a narrow one
1351
1406
  // expands it below the list instead.
@@ -1364,12 +1419,11 @@ function mapHeader(width, view) {
1364
1419
  for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: frame.length - 1, col: paneAt + button.at, height: 1 });
1365
1420
  continue;
1366
1421
  }
1367
- const badge = i - start + 1 === 10 ? '0' : String(i - start + 1);
1368
- const rowText = `${i === index ? '' : ' '} [${badge}] ${list[i].label}`;
1369
- if (!paneAt) { mark({ kind: 'item', id: list[i].id }); add(rowText, i === index ? 'selected' : 'text'); continue; }
1422
+ const rowText = `${i === index && (modal||focusRegion==='action') ? '' : ' '} ${rowLabel(list[i])}`;
1423
+ if (!paneAt) { mark({ kind: 'item', id: list[i].id }); add(rowText, i === index && (modal||focusRegion==='action') ? 'selected' : 'text'); continue; }
1370
1424
  const slot = i - start;
1371
1425
  mark({ kind: 'item', id: list[i].id, width: paneAt });
1372
- add(pad(cut(rowText, paneAt - 2), paneAt) + (pane.lines[slot] ?? ''), i === index ? 'selected' : 'text');
1426
+ add(pad(cut(rowText, paneAt - 2), paneAt) + (pane.lines[slot] ?? ''), i === index && (modal||focusRegion==='action') ? 'selected' : 'text');
1373
1427
  for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: frame.length - 1, col: paneAt + button.at, height: 1 });
1374
1428
  }
1375
1429
  if (pane) {
@@ -1382,20 +1436,30 @@ function mapHeader(width, view) {
1382
1436
  for (const button of pane.buttons[slot] ?? []) hits.push({ ...button, row: at, col: (paneAt ?? 0) + button.at, height: 1 });
1383
1437
  }
1384
1438
  }
1385
- if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · PgUp/PgDn 翻页 · 共 ${list.length} 项`, 'muted');
1386
- while (frame.length < rows - footerRows - (logLines ? logLines + 1 : 0)) add('');
1387
- if (logLines) {
1388
- add('战斗日志', 'muted');
1389
- const recent = logItems().slice(-logLines);
1390
- for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
1391
- }
1439
+ if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · 方向键继续浏览 · 共 ${list.length} 项`, 'muted');
1440
+ while (frame.length < rows - footerRows) add('');
1392
1441
  add(busy ? '操作处理中…' : pending ? note : note || (state && !synced ? '等待服务器确认状态…' : ''), pending ? 'error' : 'muted');
1393
- if(activeTab()==='potions' && !modal)add('D 丢弃药水', 'muted');
1442
+ if(detailRows){const detail=wrapText(modal?.upgradeDetail??detailText(),width).slice(0,detailRows);for(let i=detail.length;i<detailRows;i++)add('');for(const line of detail)add(cut(line,width),'muted');}
1443
+ if(frame.length>rows && detailRows){const tail=frame.splice(-detailRows);frame.splice(rows-detailRows);frame.push(...tail);}
1394
1444
  const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
1395
- return frame.slice(0, rows);
1445
+ paintFocus(frame);return frame.slice(0, rows);
1396
1446
  },
1397
1447
  key(key) {
1398
1448
  const name = key.name, value = key.value?.toLowerCase();
1449
+ if(['tab','pageup','pagedown'].includes(name))return;
1450
+ if(!modal && !targeting && state){
1451
+ if(value==='d' && !key.ctrl){openPile('deck');return;}
1452
+ if(value==='v'){void openLog();return;}
1453
+ if(value==='g' && state.phase==='combat'&&!mapNavigation()){toggleLayout();return;}
1454
+ if(name==='escape' && pileView){closePile();return;}
1455
+ if(focusRegion!=='action' && ['home','end'].includes(name)){focusIndex=name==='home'?0:Math.max(0,regionItems(focusRegion).length-1);hovered=undefined;changed();return;}
1456
+ if(focusRegion!=='action' && ['return','enter'].includes(name)){activateFocus();return;}
1457
+ }
1458
+ if(modal?.battleLog && ['left','right'].includes(name)){
1459
+ const cursor=name==='left'?modal.battleLog.after:modal.battleLog.before;
1460
+ if(cursor!=null)void openLog({[name==='left'?'after':'before']:String(cursor)});
1461
+ return;
1462
+ }
1399
1463
  if (targeting) {
1400
1464
  const ids = targetIds();
1401
1465
  if (name === 'escape') { cancelTargeting(); return; }
@@ -1405,11 +1469,6 @@ function mapHeader(width, view) {
1405
1469
  targeting.id = ids[(at + step + ids.length) % ids.length];
1406
1470
  changed(); return;
1407
1471
  }
1408
- if (['pageup', 'pagedown', 'home', 'end'].includes(name)) {
1409
- const at = name === 'home' ? 0 : name === 'end' ? ids.length - 1 : (Math.max(0, ids.indexOf(targeting.id)) + (name === 'pageup' ? -5 : 5));
1410
- targeting.id = ids[Math.max(0, Math.min(ids.length - 1, at))];
1411
- changed(); return;
1412
- }
1413
1472
  if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
1414
1473
  if (value === 'e') { cancelTargeting(); return; }
1415
1474
  }
@@ -1421,16 +1480,11 @@ function mapHeader(width, view) {
1421
1480
  if (mapNavigation()) {
1422
1481
  if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
1423
1482
  if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
1424
- if(tab===0 && ['left','right','up','down'].includes(name)) {
1483
+ if(focusRegion==='action' && tab===0 && ['left','right','up','down'].includes(name)) {
1425
1484
  const data=mapData(state.mapView), here=data.byId.get(mapFocus)??data.current??data.nodes[0];
1426
1485
  const candidates=data.nodes.filter(n=> name==='left'? n.row===here.row&&n.col<here.col : name==='right'? n.row===here.row&&n.col>here.col : name==='up'?n.row>here.row:n.row<here.row);
1427
1486
  candidates.sort((a,b)=>Math.abs(a.row-here.row)-Math.abs(b.row-here.row)||Math.abs(a.col-here.col)-Math.abs(b.col-here.col));
1428
- if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}
1429
- changed();return;
1430
- }
1431
- if(['pageup','pagedown','home','end'].includes(name)){
1432
- const drawn=mapLines(state.mapView,columns),max=Math.max(0,drawn.total-drawn.span);
1433
- mapScroll=name==='home'?max:name==='end'?0:Math.max(0,Math.min(max,(mapScroll??drawn.from)+(name==='pageup'?1:-1)*Math.max(1,drawn.span-1)));
1487
+ if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}else spatialMove(name,true);
1434
1488
  changed();return;
1435
1489
  }
1436
1490
  if(/^[0-9]$/.test(value??'')){
@@ -1438,19 +1492,18 @@ function mapHeader(width, view) {
1438
1492
  if(routes[at]){mapFocus=routes[at].action.node;mapAnchor=mapFocus;mapScroll=undefined;changed();}
1439
1493
  return;
1440
1494
  }
1441
- if(['return','enter'].includes(name)){
1495
+ if(focusRegion==='action' && ['return','enter'].includes(name)){
1442
1496
  const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
1443
1497
  if(row)void submit({...row.action,phaseId:state.phaseId});
1444
1498
  return;
1445
1499
  }
1446
1500
  }
1447
- if(mapNavigation() && ['a','s','d','e',' '].includes(value))return;
1448
- if(!modal && !targeting && !pileView && tab===0 && state?.phase==='combat' && ['left','right'].includes(name)){enemyPage=Math.max(0,enemyPage+(name==='right'?1:-1));changed();return;}
1449
- if(!modal && !targeting && state?.phase==='combat' && ['a','s','d'].includes(value) && !(value==='d' && activeTab()==='potions')){openPile({a:'draw',s:'exhaust',d:'discard'}[value]);return;}
1450
- if(name==='escape' && pileView && !modal){pileView=undefined;index=0;selected=undefined;changed();return;}
1501
+ if(mapNavigation() && ['a','s','x','e',' '].includes(value))return;
1502
+ if(!modal && !targeting && state?.phase==='combat' && ['a','s','x'].includes(value)){openPile({a:'draw',s:'discard',x:'exhaust'}[value]);return;}
1503
+ if(name==='escape' && pileView && !modal){closePile();return;}
1451
1504
  // The board takes the arrows before the option list does, or moving on
1452
1505
  // the grid would scroll the rows underneath it.
1453
- if (columns>=40 && height>=20 && sphereView() && !sphereView().finished && tab===0 && !pileView && !modal && !targeting) {
1506
+ if (focusRegion==='action' && columns>=40 && height>=20 && sphereView() && !sphereView().finished && tab===0 && !pileView && !modal && !targeting) {
1454
1507
  if (name === 'up') { sphereMove(0, -1); return; }
1455
1508
  if (name === 'down') { sphereMove(0, 1); return; }
1456
1509
  if (name === 'left') { sphereMove(-1, 0); return; }
@@ -1462,6 +1515,7 @@ function mapHeader(width, view) {
1462
1515
  if (value === ' ' || name === 'space' || name === 'return' || name === 'enter') { sphereUse(); return; }
1463
1516
  if (value === 't') { sphereTool(); return; }
1464
1517
  }
1518
+ if(!modal && !targeting && state && ['up','down','left','right'].includes(name)){spatialMove(name);return;}
1465
1519
  if (value === ' ' && !modal && !targeting) {
1466
1520
  const row = items()[index];
1467
1521
  if (row?.pick) {
@@ -1481,14 +1535,16 @@ function mapHeader(width, view) {
1481
1535
  return;
1482
1536
  }
1483
1537
  if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
1484
- if (modal) { selected = modal.previousSelected; index = modal.previousIndex; listPage = modal.previousPage ?? 0; modal = undefined; anchor(); changed(); }
1538
+ if (modal) { selected = modal.previousSelected; index = modal.previousIndex; listPage = modal.previousPage ?? 0; modal = modal.previousModal; anchor(); changed(); }
1539
+ else if(focusRegion!=='action'){focusRegion='action';hovered=undefined;changed();}
1485
1540
  else ctx.closeScreen(); return;
1486
1541
  }
1487
1542
  if (columns < 40 || height < (mapNavigation()?12:20)) return;
1488
- if (value === '?' && !modal) {
1543
+ if (value === '?' && (!modal || modal.upgradePicker)) {
1489
1544
  const row = items()[index];
1490
- const lines = ctx.wrap(row?.label ?? '方向键选择,Enter 确认;全部玩家结束回合后敌人行动。退出或掉线会暂停整局。', Math.max(1, columns - 4));
1491
- choose('说明 · ↑↓ 滚动 · Esc 返回', lines.map((label, i) => ({ id: String(i), label })), () => { anchor(); changed(); }); return;
1545
+ const previousPicker=modal?.upgradePicker?modal:undefined;
1546
+ const lines = ctx.wrap(modal?.upgradeDetail ?? (detailText() || '方向键选择,Enter 确认'), Math.max(1, columns - 4));
1547
+ choose('说明 · ↑↓ 滚动 · Esc 返回', lines.map((label, i) => ({ id: String(i), label })), () => {if(previousPicker)modal=previousPicker;anchor(); changed(); });if(previousPicker)modal.previousModal=previousPicker;return;
1492
1548
  }
1493
1549
  if (value === 'r' && !busy) {
1494
1550
  if (pending && online && synced) void submit(pending.action, true);
@@ -1498,7 +1554,6 @@ function mapHeader(width, view) {
1498
1554
  if (value === 'b' && roomId && !busy) {
1499
1555
  void guard(async () => { connection?.close(); connection = undefined; roomId = undefined; state = undefined; modal = undefined; pending = undefined; online = synced = false; selected = 'create'; index = 0; await loadRooms(); }); return;
1500
1556
  }
1501
- if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
1502
1557
  // The key and the button share one entry point.
1503
1558
  if (value === 'e' && !modal && !targeting) { endTurn(); return; }
1504
1559
  if (value === 'p' && !modal && !targeting && state) {
@@ -1521,20 +1576,15 @@ function mapHeader(width, view) {
1521
1576
  return;
1522
1577
  }
1523
1578
  if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
1524
- if (value === 'd' && !modal && activeTab() === 'potions' && canAct()) {
1525
- if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
1526
- const row = items()[index]; if (row?.potion) choose('丢弃这瓶药水?', [{ id: 'no', label: '保留' }, { id: 'yes', label: '丢弃' }], answer => { if (answer.id === 'yes') void submit({ type: 'dropPotion', potion: row.id }); else changed(); }); return;
1527
- }
1528
- if (modal?.inspection && ['pageup','pagedown'].includes(name)) {
1529
- const wanted = modal.inspection.page + (name === 'pageup' ? -1 : 1);
1530
- if (wanted < 0 || wanted >= modal.inspection.pages) return;
1531
- void inspectPlayer(modal.inspection.id, wanted); return;
1532
- }
1533
- if (modal?.detail && ['pageup','pagedown'].includes(name)) { pageDetail(name === 'pageup' ? -3 : 3); return; }
1534
- if (name === 'pageup' || name === 'pagedown') {
1535
- const pages = Math.max(1, Math.ceil(items().length / pageSize));
1536
- listPage = Math.max(0, Math.min(pages - 1, listPage + (name === 'pagedown' ? 1 : -1)));
1537
- moveTo(listPage * pageSize); return;
1579
+ if(modal && ['up','down'].includes(name)){
1580
+ const step=name==='up'?-1:1,boundary=index+step<0||index+step>=items().length;
1581
+ if(boundary && modal.battleLog){
1582
+ const cursor=step<0?modal.battleLog.after:modal.battleLog.before;
1583
+ if(cursor!=null&&!busy)void openLog({[step<0?'after':'before']:String(cursor)},step<0);
1584
+ return;
1585
+ }
1586
+ if(boundary && modal.inspection){const view=modal.inspection,page=view.page+step;if(page>=0&&page<view.pages)void inspectPlayer(view.id,page,step<0);return;}
1587
+ if(modal.detail && boundary){pageDetail(step);return;}
1538
1588
  }
1539
1589
  if (['up', 'down', 'home', 'end'].includes(name)) {
1540
1590
  const list = items();
@@ -1547,6 +1597,27 @@ function mapHeader(width, view) {
1547
1597
  },
1548
1598
  mouse(event) {
1549
1599
  if (columns < 40 || height < (mapNavigation()?12:20)) return;
1600
+ if(!modal && !targeting){
1601
+ const hit=hitAt(event.x,event.y);
1602
+ if(event.action==='move'){
1603
+ let next;
1604
+ if(hit?.kind==='equipment')next={region:hit.region,row:regionItems(hit.region).find(row=>row.id===hit.id)};
1605
+ else if(hit?.kind==='entity')next={region:'units',row:regionItems('units').find(row=>String(row.id)===String(hit.id))};
1606
+ if(hovered?.row?.id!==next?.row?.id){hovered=next;changed();}
1607
+ if(next)return;
1608
+ }
1609
+ if(event.action==='press' && event.button==='left'){
1610
+ if(hit?.kind==='entry'){if(hit.id==='deck')openPile('deck');else void openLog();return;}
1611
+ if(hit?.kind==='equipmentPage'){
1612
+ equipmentOffset[hit.region]=Math.max(0,Math.min(regionItems(hit.region).length-1,equipmentOffset[hit.region]+hit.delta));
1613
+ focusRegion=hit.region;focusIndex=equipmentOffset[hit.region];hovered=undefined;changed();return;
1614
+ }
1615
+ if(hit?.kind==='equipment'){
1616
+ focusRegion=hit.region;focusIndex=hit.index;hovered=undefined;
1617
+ if(hit.region==='potions')potionMenu(focusItem());else changed();return;
1618
+ }
1619
+ }
1620
+ }
1550
1621
  if (event.action === 'move') {
1551
1622
  // Hover lights a row, or shows which target the pointer is on.
1552
1623
  const pointed = hitAt(event.x, event.y);
@@ -1556,6 +1627,7 @@ function mapHeader(width, view) {
1556
1627
  }
1557
1628
  // §D: hovering a map node looks at it; pointing never submits.
1558
1629
  if (pointed?.kind === 'node') {
1630
+ focusRegion='action';
1559
1631
  if (mapFocus !== pointed.id) { mapFocus = pointed.id; changed(); }
1560
1632
  return;
1561
1633
  }
@@ -1566,6 +1638,7 @@ function mapHeader(width, view) {
1566
1638
  if (event.button === 'left') {
1567
1639
  const hit = hitAt(event.x, event.y);
1568
1640
  if (hit?.kind === 'node') {
1641
+ focusRegion='action';
1569
1642
  // §D: clicking a room you can travel to is the vote itself; any other
1570
1643
  // room is only looked at, and parks the view there.
1571
1644
  mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
@@ -1585,7 +1658,7 @@ function mapHeader(width, view) {
1585
1658
  void submit(row.action);
1586
1659
  return;
1587
1660
  }
1588
- if (hit?.kind === 'managePotions') { switchTab(tabs.indexOf('potions')); return; }
1661
+ if (hit?.kind === 'managePotions') { focusRegion='potions';focusIndex=0;changed();return; }
1589
1662
  if (hit?.kind === 'mapCenter') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
1590
1663
  if (hit?.kind === 'mapFull') { mapFull = !mapFull; mapScroll = undefined; changed(); return; }
1591
1664
  if (hit?.kind === 'endTurn') { endTurn(); return; }
@@ -1597,7 +1670,7 @@ function mapHeader(width, view) {
1597
1670
  }
1598
1671
  if (event.button === 'wheel-up' || event.button === 'wheel-down') {
1599
1672
  const step = event.button === 'wheel-up' ? -1 : 1;
1600
- // An open detail pane pages first, exactly like PgUp/PgDn do.
1673
+ // An open detail pane pages first, before moving the option list.
1601
1674
  if (modal?.detail) { pageDetail(step * 3); return; }
1602
1675
  // §D: on the map the wheel looks around the act instead of moving the cursor.
1603
1676
  if (mapNavigation()) {
@@ -1612,10 +1685,7 @@ function mapHeader(width, view) {
1612
1685
  const hit = hitAt(event.x, event.y);
1613
1686
  if (!hit) return;
1614
1687
  if (hit.kind === 'item') { clickRow(hit.id); return; }
1615
- if (hit.kind === 'tab' && !modal && state) {
1616
- const span = hit.spans?.find(range => event.x >= range.from && event.x < range.to);
1617
- if (span) switchTab(span.tab); else if (hit.advance) switchTab(tab + 1);
1618
- }
1688
+
1619
1689
  },
1620
1690
  };
1621
1691
  return { screen, start: () => guard(loadRooms), stop: () => { closed = true; connection?.close(); }, accept };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkchat-extension-spire",
3
- "version": "0.3.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "Optional cooperative card-climbing TUI for LinkChat",
6
6
  "license": "UNLICENSED",