linkchat-extension-spire 0.1.8 → 0.1.9

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 +5 -5
  2. package/index.js +104 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
- # Spire for LinkChat — 0.1.8
1
+ # Spire for LinkChat — 0.1.9
2
2
 
3
3
  按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-2`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
4
4
 
5
- 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.8.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
5
+ 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.9.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
6
6
 
7
7
  ## 操作
8
8
 
9
- **战斗**:我方在左、敌人在右(宽度不足 100 列或高度不足 30 行时改为上下分区);每个信息块自上而下是意图、名称、格挡与血条、生命、增益、减益,多个意图字段会换行而不是被截断。手牌竖排并编号 1–9、0:数字键只把光标移到该位置,Enter 才是执行;每页最多十项,PgUp/PgDn 翻页,方向键跨页时页码同步。能量右侧是固定的 `[E 结束回合]` 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
9
+ **战斗**:我方在左、敌人在右(默认 80×25 终端使用紧凑双栏,队友在左、敌人在右;有效宽度不足 68 列或高度不足 24 行时改为上下分区);每个信息块自上而下是意图、名称、格挡与血条、生命、增益、减益,多个意图字段会换行而不是被截断。手牌竖排并编号 1–9、0:数字键只把光标移到该位置,Enter 才是执行;每页最多十项,PgUp/PgDn 翻页,方向键跨页时页码同步。能量右侧是固定的 `[E 结束回合]` 按钮,和 E 键共用同一套校验,按钮会显示 `[已结束回合]` 或 `[等待队友]`。
10
10
 
11
11
  **选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
12
12
 
13
- **地图**:普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
13
+ **地图**:开局先确认当前角色或选择其他角色,再显示路线。M 打开/关闭地图,战斗中查看地图不会提交路线投票。普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
14
14
 
15
15
  **商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
16
16
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  **宝箱**:单人时点选遗物即为一次明确的领取,客户端会在服务端确认后接着领取同一件遗物(按 reward ID 精确匹配,结果不明时不会猜);多人保留投票、分配、领取三个阶段,投票按钮本身就是确认。
20
20
 
21
- **其他**:Tab 依次查看牌组、抽牌堆、弃牌堆、消耗堆、遗物、药水、状态、日志;Esc 返回或暂停退出;药水面板 D 丢弃药水;多选可用数字键定位、空格勾选、确认行提交,规则允许空选时会给出明确的取消项。请求结果不明时按 R 重试同一操作,不会重复出牌。
21
+ **其他**:Tab 依次查看行动、牌组、遗物、药水、状态、日志,标签随窗口宽度换行;抽牌堆、消耗堆和弃牌堆仅在战斗中显示于能量栏下一行,依次位于左、中、右,点击或按 A / S / D 查看(药水面板的 D 仍为丢弃药水),Esc 返回;Esc 返回或暂停退出;药水面板 D 丢弃药水;多选可用数字键定位、空格勾选、确认行提交,规则允许空选时会给出明确的取消项。请求结果不明时按 R 重试同一操作,不会重复出牌。
22
22
 
23
23
  **鼠标**(需要客户端宿主 0.7.0 及以上):鼠标移到某行即高亮该行(等同选中),**单击即生效**;点顶部面板名切换面板;滚轮滚动列表(地图页滚动地图),弹窗有长说明时滚轮翻页。游戏界面会接管鼠标,想在终端里选中或复制文本请按住 Shift 拖拽。旧宿主上鼠标点击不会生效。
24
24
 
package/index.js CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  const BASE = '/api/games/spire/rooms';
3
3
  const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
4
4
  export const phases = { lobby: '等待队友', map: '路线投票', combat: '战斗', reward: '战利品', rest: '休息', shop: '商店', event: '事件', treasure: '宝箱', actComplete: '本幕完成', ending: '结局事件', victory: '通关', defeat: '攀登结束', abandoned: '已放弃' };
5
- const tabs = ['action', 'deck', 'draw', 'discard', 'exhaust', 'relics', 'potions', 'status', 'log'];
5
+ const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
6
6
  const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
7
7
  const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
8
8
  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:'虚无免费'});
@@ -136,10 +136,14 @@ function mapVotes(node, players) {
136
136
  }
137
137
  function drawMap(view, width, session = {}) {
138
138
  const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
139
- const focus = session.focus ?? view.current;
139
+ const focus = data.byId.has(session.focus) ? session.focus : view.current;
140
+ const codeLabel = n => `${n.id===focus?'›':' '} ${mapCode(n)}`;
140
141
  const labels = new Map(data.nodes.map(n => [n.id, mapLabel(view, data, n, session.options)]));
141
- const slot = Math.max(12, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 2));
142
142
  const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
143
+ // An 80-column terminal gives the extension 75 columns after host margins.
144
+ // Reserve only measured text plus a separator before falling back to a table.
145
+ const needed = Math.max(8, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 1));
146
+ const slot = Math.max(needed, width >= cols * 12 ? 12 : 6);
143
147
  const unsafeGap = layers.some((layer,i)=>{
144
148
  const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
145
149
  const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
@@ -154,7 +158,7 @@ function drawMap(view, width, session = {}) {
154
158
  const rooms = data.rows.get(layers[i]), block = [];
155
159
  if (compact) {
156
160
  for (const n of rooms) {
157
- const label = `${mapCode(n)} ${labels.get(n.id)}`;
161
+ const label = `${codeLabel(n)} ${labels.get(n.id)}`;
158
162
  const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
159
163
  const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
160
164
  let suffix='';
@@ -169,7 +173,7 @@ function drawMap(view, width, session = {}) {
169
173
  } else {
170
174
  for (const codes of [true,false]) {
171
175
  const pieces = rooms.map(n => {
172
- const text = codes ? mapCode(n) : labels.get(n.id);
176
+ const text = codes ? codeLabel(n) : labels.get(n.id);
173
177
  return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
174
178
  });
175
179
  const placed = place(pieces,width);
@@ -294,7 +298,11 @@ export function createScreen(ctx) {
294
298
  const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
295
299
  const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
296
300
  const me = () => state?.players.find(p => p.id === ctx.user.id);
297
- const activeTab = () => tabs[tab];
301
+ let characterConfirmed=false, mapOpen, mapReturnTab=0;
302
+ const needsCharacterChoice=()=>!characterConfirmed && !state?.paused && ['lobby','map'].includes(state?.phase) && state?.options.some(row=>row.action?.type==='character');
303
+ let pileView;
304
+ const activeTab = () => pileView ?? tabs[tab];
305
+ function openPile(key) { if(state?.phase!=='combat' || modal)return; pileView=key; index=0;selected=undefined;listPage=0;changed(); }
298
306
  const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
299
307
  // A phase this build cannot render stays read only, so nothing is submitted.
300
308
  const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
@@ -303,6 +311,7 @@ export function createScreen(ctx) {
303
311
  if (modal) return modal.items;
304
312
  if (!roomId) return [{ id: 'create', label: '创建合作房间(2–4 人)' }, {id:'solo',label:'单人攀登 · 开始/继续'}, ...rooms.filter(r=>r.mode!=='solo').map(r => ({ id: r.id, room: r, label: `${r.players.some(p => p.id === ctx.user.id) ? '继续' : '加入'} · ${r.players.map(p => p.name).join(' / ')} · ${r.players.length}/4 · ${phases[r.phase] ?? r.phase}${r.paused ? ' · 已暂停' : ''}${r.compatible === false ? ' · 版本不兼容' : ''}` }))];
305
313
  if (!state) return [];
314
+ if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
306
315
  if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
307
316
  if (activeTab() === 'relics') return state.self.relics.map(r => ({ id: r.id ?? r.key, label: `${r.name}:${r.description}` }));
308
317
  if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
@@ -351,6 +360,7 @@ export function createScreen(ctx) {
351
360
  : { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
352
361
  return rows;
353
362
  }
363
+ if(state.phase==='map' && !state.paused && state.mapView?.nodes?.length)return [{id:'openMap',label:'打开地图 M'}];
354
364
  return state.options;
355
365
  }
356
366
  function anchor() {
@@ -379,7 +389,7 @@ export function createScreen(ctx) {
379
389
  mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
380
390
  }
381
391
  if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
382
- modal = undefined; selected = undefined; index = 0; tab = 0;
392
+ modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
383
393
  rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
384
394
  }
385
395
  if (s.leaseId) { leaseId = s.leaseId; synced = true; }
@@ -413,7 +423,7 @@ export function createScreen(ctx) {
413
423
  serverProtocol = result.protocol; rooms = result.rooms; anchor(); loading = false;
414
424
  }
415
425
  function connect(id) {
416
- connection?.close(); roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
426
+ connection?.close(); characterConfirmed=false;mapOpen=undefined; roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
417
427
  connection = ctx.connect(`/ws/games/spire/rooms/${id}`, accept, connected => {
418
428
  online = connected; synced = false;
419
429
  if (!connected) note = '连接中断,正在重连;操作已暂停。';
@@ -428,6 +438,7 @@ export function createScreen(ctx) {
428
438
  const envelope = { ...pending, leaseId };
429
439
  try {
430
440
  const response = await ctx.request(`${BASE}/${roomId}/actions`, 'POST', envelope);
441
+ if(envelope.action.type==='character'){characterConfirmed=true;mapOpen=undefined;}
431
442
  pending = undefined; note = ''; modal = undefined; accept(response);
432
443
  } catch (e) {
433
444
  // Unknown transport outcomes retain the operation id; R retries exactly
@@ -461,7 +472,7 @@ export function createScreen(ctx) {
461
472
  }
462
473
  // The keyboard branches and clicks move the same state through these, so the
463
474
  // two input paths cannot drift apart.
464
- function switchTab(next) { tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
475
+ function switchTab(next) { pileView=undefined; tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
465
476
  function moveTo(next) { const list = items(); index = Math.max(0, Math.min(next, list.length - 1)); selected = list[index]?.id; changed(); }
466
477
  function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
467
478
  function clickRow(id) {
@@ -862,6 +873,8 @@ function mapHeader(width, view) {
862
873
  }); return;
863
874
  }
864
875
  if (!canAct()) return;
876
+ if(row.id==='keepCharacter'){characterConfirmed=true;mapOpen=undefined;selected=undefined;index=0;changed();return;}
877
+ if(row.id==='openMap'){mapOpen=true;mapReturnTab=tab;tab=0;changed();return;}
865
878
  if (tab === 0 && row.pick) {
866
879
  const limit = state.pendingChoice?.maximum ?? state.pendingDecision?.maximum ?? 1;
867
880
  if (picked.has(row.pick)) picked.delete(row.pick);
@@ -869,7 +882,7 @@ function mapHeader(width, view) {
869
882
  else note=`最多选择 ${limit} 张牌。`;
870
883
  changed();return;
871
884
  }
872
- if (tab === 0 && row.card) { cardAction(row.card); return; }
885
+ if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
873
886
  if (activeTab() === 'potions' && row.potion) {
874
887
  if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
875
888
  if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
@@ -997,7 +1010,7 @@ function mapHeader(width, view) {
997
1010
  // The drawing reads the view state and the party; this supplies both.
998
1011
  const mapLines = (view, width) => drawMap(view, width,
999
1012
  { full: mapFull, scroll: mapScroll, anchor: mapAnchor, focus:mapFocus, height:mapBudget, players: state?.players, options: state?.options });
1000
- const mapNavigation = () => !modal && tab===0 && state?.phase==='map' && !state.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
1013
+ const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
1001
1014
  const screen = {
1002
1015
  render(width, rows) {
1003
1016
  columns = width; height = rows; hits = [];
@@ -1005,7 +1018,7 @@ function mapHeader(width, view) {
1005
1018
  const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
1006
1019
  if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
1007
1020
  const own = me();
1008
- const heading = state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
1021
+ const heading = needsCharacterChoice() ? '选择初始角色' : state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
1009
1022
  const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
1010
1023
  add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
1011
1024
  if(mapNavigation()) {
@@ -1022,9 +1035,9 @@ function mapHeader(width, view) {
1022
1035
  add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
1023
1036
  add(cut(mapDetailText(view,mapData(view)),width),'muted');
1024
1037
  add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
1025
- add(cut('↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览',width),'muted');
1038
+ add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
1026
1039
  mark({kind:'tab',advance:true});
1027
- add(cut('[面板 Tab] · 悬停查看 · Esc 返回 · R 重试',width),'muted');
1040
+ add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
1028
1041
  hits=hits.filter(hit=>hit.row<rows);
1029
1042
  return frame.slice(0,rows);
1030
1043
  }
@@ -1039,7 +1052,41 @@ function mapHeader(width, view) {
1039
1052
  const partyBlocks = party.map(p => allyBlock(p));
1040
1053
  const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
1041
1054
  const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
1042
- if (wideBattlefield()) {
1055
+ if(width>=68 && rows>=24 && !wideBattlefield()) {
1056
+ // Medium terminals keep the two teams side by side. Party members
1057
+ // stack in the left column; enemies page independently on the right.
1058
+ const leftWidth=Math.floor((width-3)/2),rightWidth=width-leftWidth-3;
1059
+ const budget=Math.max(4,rows-frame.length-10);
1060
+ const room=Math.max(2,Math.min(5,Math.floor(budget/party.length)));
1061
+ const perPage=Math.max(1,Math.floor(budget/room));
1062
+ const pages=Math.max(1,Math.ceil(alive.length/perPage));
1063
+ if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
1064
+ enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
1065
+ const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
1066
+ const compactBlock=(creature,arena,span)=>{
1067
+ const name=`${arena==='ally'&&creature.id===ctx.user.id?'› ':''}${creature.name}${creature.done?' · 已结束':''}`;
1068
+ const health=`${hpText(creature)} HP · 格挡 ${creature.block}`;
1069
+ const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
1070
+ const powers=powerEntries(creature).map(p=>p.text).join(' · ');
1071
+ const body=[name,health];
1072
+ if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
1073
+ if(room>=3)body.push(intent||powers||'无额外状态');
1074
+ if(room>=5)body.push(intent?powers:'');
1075
+ return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
1076
+ };
1077
+ const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
1078
+ shown.map(e=>({id:e.id,lines:compactBlock(e,'enemy',rightWidth),legal:legalEnemy(e)}))];
1079
+ const startRow=frame.length,rendered=[[],[]];
1080
+ columns.forEach((entries,side)=>entries.forEach(entry=>{
1081
+ const top=rendered[side].length;
1082
+ const body=entry.legal?tint(entry.lines,targetTint(entry,targeting.arena),aimed(entry)):entry.lines;
1083
+ rendered[side].push(...body);
1084
+ while(rendered[side].length<top+room)rendered[side].push('');
1085
+ hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
1086
+ }));
1087
+ for(let i=0;i<Math.max(...rendered.map(c=>c.length));i++)add(pad(rendered[0][i]??'',leftWidth)+' │ '+(rendered[1][i]??''));
1088
+ if(pages>1){mark({kind:'enemyPage',pages});add(`敌人 ${enemyPage+1}/${pages} 页 · ←/→ 或点击翻页`,'muted');}
1089
+ } else if (wideBattlefield()) {
1043
1090
  // The party column is as wide as what it prints (a fixed third used to
1044
1091
  // cut 增益/减益 short); the enemies share what is left.
1045
1092
  const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
@@ -1105,6 +1152,13 @@ function mapHeader(width, view) {
1105
1152
  const buttonAt = Math.max(8, width - displayWidth(button) - 1);
1106
1153
  if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
1107
1154
  add(pad(energy, buttonAt) + button, 'title');
1155
+ const piles=[['draw','A','抽牌堆',state.self.draw?.length??state.self.drawCount??0],
1156
+ ['exhaust','S','消耗堆',state.self.exhaust?.length??state.self.exhaustCount??0],
1157
+ ['discard','D','弃牌堆',state.self.discard.length]];
1158
+ const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
1159
+ const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
1160
+ if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
1161
+ add(place(pileLabels.map((text,i)=>({at:positions[i],text})),width).text,'muted');
1108
1162
  } else {
1109
1163
  add(`${state.event ?? ''} 金币 ${own?.gold ?? 0}`, 'muted');
1110
1164
  // §2: outside combat there is no battlefield, so the party, its health
@@ -1120,11 +1174,23 @@ function mapHeader(width, view) {
1120
1174
  const labels = tabs.map((key, i) => {
1121
1175
  const count = ['deck', 'draw', 'discard', 'exhaust'].includes(key) ? state.self[key]?.length ?? (key === 'draw' ? state.self.drawCount : key === 'exhaust' ? state.self.exhaustCount : 0) : undefined;
1122
1176
  const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
1123
- return i === tab ? `[${label}]` : label;
1177
+ return !pileView && i === tab ? `[${label}]` : label;
1124
1178
  });
1125
1179
  const decision = state.pendingChoice ?? state.pendingDecision;
1126
- if (!modal && !(tab === 0 && decision)) mark(width < 100 ? { kind: 'tab', advance: true } : { kind: 'tab', spans: tabSpans(labels) });
1127
- add(modal?.title ?? (tab === 0 && decision ? `${decision.owner === ctx.user.id ? '' : '等待队友 · '}${decision.title}` : width < 100 ? `${labels[tab]} · Tab 切换面板` : labels.join(' · ')), 'title');
1180
+ if(modal || (tab===0 && decision)) {
1181
+ add(modal?.title ?? `${decision.owner===ctx.user.id?'':'等待队友 · '}${decision.title}`,'title');
1182
+ }else {
1183
+ let text='',spans=[];
1184
+ const flush=()=>{mark({kind:'tab',spans});add(text,'title');text='';spans=[];};
1185
+ for(let i=0;i<labels.length;i++){
1186
+ const label=labels[i];
1187
+ if(text && displayWidth(text+' · '+label)>width)flush();
1188
+ const from=displayWidth(text)+(text?3:0);
1189
+ text+=(text?' · ':'')+label;spans.push({tab:i,from,to:from+displayWidth(label)});
1190
+ }
1191
+ if(text)flush();
1192
+ if(pileView)add(`${tabNames[pileView]} · Esc 返回 · Tab 切换面板`,'title');
1193
+ }
1128
1194
  if (modal?.detail) {
1129
1195
  const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
1130
1196
  const count = Math.max(1, Math.min(6, rows - frame.length - 9));
@@ -1135,14 +1201,15 @@ function mapHeader(width, view) {
1135
1201
  }
1136
1202
  } else add('频道成员可加入 · R 刷新房间列表', 'muted');
1137
1203
  if (loading) add('正在加载…', 'muted');
1138
- const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - 8)) : 0;
1139
- if (state) add(''); // breathing room above the list
1140
- anchor(); const list = items(), available = Math.max(1, rows - frame.length - 3 - (logLines ? logLines + 1 : 0));
1204
+ const footerRows=activeTab()==='potions' && !modal ? 2 : 1;
1205
+ const logLines = state?.phase === 'combat' && !modal && activeTab() !== 'log' ? Math.min(2, Math.max(0, rows - frame.length - footerRows - 3)) : 0;
1206
+ if (state && state.phase!=='combat') add(''); // breathing room above the list
1207
+ anchor(); const list = items(), available = Math.max(1, rows - frame.length - footerRows - 1 - (logLines ? logLines + 1 : 0));
1141
1208
  pageSize = available;
1142
1209
  const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
1143
1210
  listPage = Math.max(0, Math.min(maxPage, listPage));
1144
1211
  const start = listPage * available;
1145
- if (!list.length && !loading) add(state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
1212
+ if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
1146
1213
  // §E: a wide frame puts the shop's detail beside the goods; a narrow one
1147
1214
  // expands it below the list instead.
1148
1215
  const pane = state && state.phase === 'shop' && !modal && list[index]?.shop
@@ -1179,15 +1246,14 @@ function mapHeader(width, view) {
1179
1246
  }
1180
1247
  }
1181
1248
  if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · PgUp/PgDn 翻页 · 共 ${list.length} 项`, 'muted');
1182
- while (frame.length < rows - 3 - (logLines ? logLines + 1 : 0)) add('');
1249
+ while (frame.length < rows - footerRows - (logLines ? logLines + 1 : 0)) add('');
1183
1250
  if (logLines) {
1184
1251
  add('战斗日志', 'muted');
1185
1252
  const recent = logItems().slice(-logLines);
1186
1253
  for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
1187
1254
  }
1188
1255
  add(busy ? '操作处理中…' : pending ? note : note || (state && !synced ? '等待服务器确认状态…' : ''), pending ? 'error' : 'muted');
1189
- add(roomId ? '↑↓ / 1–0 选择 · Enter 确认 · Tab 面板 · E 结束回合 · PgUp/PgDn 翻页' : '↑↓ 选择 · Enter 创建/加入 · R 刷新', 'muted');
1190
- add('Esc 返回 / 暂停退出 · ? 说明 · R 重试 · B 房间 · D 丢弃药水', 'muted');
1256
+ if(activeTab()==='potions' && !modal)add('D 丢弃药水', 'muted');
1191
1257
  const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
1192
1258
  return frame.slice(0, rows);
1193
1259
  },
@@ -1210,6 +1276,11 @@ function mapHeader(width, view) {
1210
1276
  if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
1211
1277
  if (value === 'e') { cancelTargeting(); return; }
1212
1278
  }
1279
+ if(value==='m' && state?.mapView?.nodes?.length && !modal && !targeting && !state.paused && !needsCharacterChoice() && !state.pendingChoice && !state.pendingDecision){
1280
+ if(mapNavigation()){mapOpen=false;tab=mapReturnTab;}
1281
+ else{mapReturnTab=tab;tab=0;mapOpen=true;}
1282
+ changed();return;
1283
+ }
1213
1284
  if (mapNavigation()) {
1214
1285
  if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
1215
1286
  if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
@@ -1231,11 +1302,15 @@ function mapHeader(width, view) {
1231
1302
  return;
1232
1303
  }
1233
1304
  if(['return','enter'].includes(name)){
1234
- const row=items().find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
1305
+ const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
1235
1306
  if(row)void submit({...row.action,phaseId:state.phaseId});
1236
1307
  return;
1237
1308
  }
1238
1309
  }
1310
+ if(mapNavigation() && ['a','s','d','e',' '].includes(value))return;
1311
+ if(!modal && !targeting && !pileView && tab===0 && state?.phase==='combat' && ['left','right'].includes(name)){enemyPage=Math.max(0,enemyPage+(name==='right'?1:-1));changed();return;}
1312
+ if(!modal && !targeting && state?.phase==='combat' && ['a','s','d'].includes(value) && !(value==='d' && activeTab()==='potions')){openPile({a:'draw',s:'exhaust',d:'discard'}[value]);return;}
1313
+ if(name==='escape' && pileView && !modal){pileView=undefined;index=0;selected=undefined;changed();return;}
1239
1314
  if (value === ' ' && !modal && !targeting) {
1240
1315
  const row = items()[index];
1241
1316
  if (row?.pick) {
@@ -1273,7 +1348,6 @@ function mapHeader(width, view) {
1273
1348
  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;
1274
1349
  }
1275
1350
  if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
1276
- if (value === 's' && !modal && state) { switchTab(tabs.indexOf('status')); return; }
1277
1351
  // The key and the button share one entry point.
1278
1352
  if (value === 'e' && !modal && !targeting) { endTurn(); return; }
1279
1353
  if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
@@ -1320,11 +1394,13 @@ function mapHeader(width, view) {
1320
1394
  // §D: clicking a room you can travel to is the vote itself; any other
1321
1395
  // room is only looked at, and parks the view there.
1322
1396
  mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
1323
- const row = items().find(row => row.action?.type === 'vote' && row.id === hit.id);
1397
+ const row = state.phase==='map' && state.options.find(row => row.action?.type === 'vote' && row.action.node === hit.id);
1324
1398
  if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
1325
1399
  changed();
1326
1400
  return;
1327
1401
  }
1402
+ if(hit?.kind==='enemyPage' && !targeting && !modal){enemyPage=(enemyPage+1)%hit.pages;changed();return;}
1403
+ if (hit?.kind === 'pile') {openPile(hit.id);return;}
1328
1404
  if (hit?.kind === 'shopBuy') {
1329
1405
  const row = items()[index];
1330
1406
  const price = row?.shop?.price ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkchat-extension-spire",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Optional cooperative card-climbing TUI for LinkChat",
6
6
  "license": "UNLICENSED",