linkchat-extension-spire 0.1.8 → 0.3.0

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 +14 -6
  2. package/index.js +300 -48
  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.3.0
2
2
 
3
- 按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-2`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
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.1.8.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
5
+ 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.3.0.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
 
@@ -31,3 +31,11 @@
31
31
  ## 其他
32
32
 
33
33
  扩展只提供游戏界面,存档在服务器,卸载扩展不会删除存档。正式客户端 0.6.3 不包含扩展功能。此版本为正式版。
34
+
35
+ ## 协议 5 配套更新
36
+
37
+ 本版必须与协议 5 的 Spire 后端一起更新。协议 1–4 会明确提示版本不匹配并阻止游戏操作。聊天 CLI 主程序无需重新发布。
38
+
39
+ 升级预览按当前牌堆读取卡牌,保留战斗中的临时费用;队友面板关闭后恢复原选择。水晶球支持方向键、Enter、空格和鼠标操作。
40
+
41
+ 进阶 A0–A10 全部开放,默认 A0。在创建房间前选择“新局难度”,创建后固定;继续已有单人存档保留原等级。扩展 0.3.0 使用游戏协议 5,需要配套后端。
package/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ const PROTOCOL = 5;
3
+ const VERSION_ERROR = 'Spire 客户端与服务器版本不匹配,请配套升级。';
2
4
  const BASE = '/api/games/spire/rooms';
3
5
  const hpText = creature => creature.awaitingRevive ? '0(待复活)' : creature.hpDisplay === 'infinite' && creature.hp > 0 ? '∞' : `${creature.hp}/${creature.maxHp}`;
4
6
  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'];
7
+ const tabs = ['action', 'deck', 'relics', 'potions', 'status', 'log'];
6
8
  const tabNames = {action:'行动',deck:'牌组',draw:'抽牌堆',discard:'弃牌堆',exhaust:'消耗堆',relics:'遗物',potions:'药水',status:'状态',log:'日志'};
7
9
  const powerNames = {strength:'力量',dexterity:'敏捷',weak:'虚弱',vulnerable:'易伤',frail:'脆弱',rage:'狂怒',thorns:'反伤',demonForm:'恶魔形态',darkEmbrace:'黑暗之拥',feelNoPain:'无惧疼痛',rupture:'撕裂',noDraw:'无法抽牌',noBlock:'无法从卡牌获得格挡',darkShackles:'黑暗枷锁',slow:'缓慢',slippery:'滑溜',paperCuts:'纸割'};
8
10
  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:'虚无免费'});
@@ -46,7 +48,7 @@ Object.assign(powerNames,{ravenous:'食尸增力',plating:'覆甲',tangled:'缠
46
48
  // moved on, and the screen has to say so rather than refuse to open. Only the
47
49
  // phase check is relaxed, and the caller has to ask for that explicitly.
48
50
  function wellFormed(s, knownPhase) {
49
- return !!s && [1,2].includes(s.protocol) && typeof s.roomId === 'string' && Number.isSafeInteger(s.revision) && Number.isSafeInteger(s.phaseId) && (knownPhase ? Object.hasOwn(phases, s.phase) : typeof s.phase === 'string')
51
+ return !!s && s.protocol === PROTOCOL && typeof s.roomId === 'string' && Number.isSafeInteger(s.revision) && Number.isSafeInteger(s.phaseId) && (knownPhase ? Object.hasOwn(phases, s.phase) : typeof s.phase === 'string')
50
52
  && Array.isArray(s.players) && s.players.length <= 4 && s.players.every(p => Number.isSafeInteger(p.id) && typeof p.name === 'string')
51
53
  && Array.isArray(s.enemies) && s.enemies.length <= 12 && s.enemies.every(e => typeof e.id === 'string' && typeof e.name === 'string' && e.intent)
52
54
  && (!s.act || Number.isInteger(s.act.index) && s.act.index>=0 && s.act.index<3 && typeof s.act.name==='string')
@@ -58,6 +60,14 @@ function wellFormed(s, knownPhase) {
58
60
  && s.players.every(p=>p.companions===undefined || Array.isArray(p.companions) && p.companions.length<=3 && p.companions.every(pet=>pet && typeof pet.name==='string' && (pet.cooldown===undefined || Number.isSafeInteger(pet.cooldown) && pet.cooldown>=0)))
59
61
  && s.players.every(p=>p.monologueInstances===undefined || Array.isArray(p.monologueInstances) && p.monologueInstances.every(record=>record && Number.isSafeInteger(record.strength) && record.strength>0 && Number.isSafeInteger(record.applied) && record.applied>=0))
60
62
  && (!s.mapView || typeof s.mapView === 'object' && s.mapView.nodes && s.mapView.nodes.length <= 200 && Array.isArray(s.mapView.nodes) && s.mapView.nodes.every(n => n && typeof n.id === 'string' && Number.isSafeInteger(n.row) && Number.isSafeInteger(n.col) && typeof n.type === 'string' && (n.children === undefined || Array.isArray(n.children))))
63
+ && (!!s.endTurn && typeof s.endTurn === 'object' && typeof s.endTurn.canEndTurn === 'boolean' && typeof s.endTurn.canUndoEndTurn === 'boolean' && typeof s.endTurn.reason === 'string')
64
+ && (!s.eventProgress?.sphere || (Number.isSafeInteger(s.eventProgress.sphere.divinations) && s.eventProgress.sphere.divinations >= 0
65
+ && Number.isSafeInteger(s.eventProgress.sphere.width) && s.eventProgress.sphere.width > 0 && s.eventProgress.sphere.width <= 32
66
+ && Number.isSafeInteger(s.eventProgress.sphere.height) && s.eventProgress.sphere.height > 0 && s.eventProgress.sphere.height <= 32
67
+ && typeof s.eventProgress.sphere.tool === 'string' && typeof s.eventProgress.sphere.finished === 'boolean'
68
+ && Array.isArray(s.eventProgress.sphere.cells) && s.eventProgress.sphere.cells.length <= 1024
69
+ && s.eventProgress.sphere.cells.every(cell => cell && Number.isSafeInteger(cell.x) && Number.isSafeInteger(cell.y)
70
+ && (cell.item === null || cell.item === undefined || typeof cell.item === 'string'))))
61
71
  && Array.isArray(s.options) && Array.isArray(s.log) && s.log.every(entry => typeof entry === 'string' || entry && Number.isSafeInteger(entry.id) && typeof entry.text === 'string');
62
72
  }
63
73
  export function validState(s) { return wellFormed(s, true); }
@@ -136,10 +146,14 @@ function mapVotes(node, players) {
136
146
  }
137
147
  function drawMap(view, width, session = {}) {
138
148
  const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
139
- const focus = session.focus ?? view.current;
149
+ const focus = data.byId.has(session.focus) ? session.focus : view.current;
150
+ const codeLabel = n => `${n.id===focus?'›':' '} ${mapCode(n)}`;
140
151
  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
152
  const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
153
+ // An 80-column terminal gives the extension 75 columns after host margins.
154
+ // Reserve only measured text plus a separator before falling back to a table.
155
+ const needed = Math.max(8, ...data.nodes.map(n => displayWidth(labels.get(n.id)) + mapVotes(n, session.players).width + 1));
156
+ const slot = Math.max(needed, width >= cols * 12 ? 12 : 6);
143
157
  const unsafeGap = layers.some((layer,i)=>{
144
158
  const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
145
159
  const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
@@ -154,7 +168,7 @@ function drawMap(view, width, session = {}) {
154
168
  const rooms = data.rows.get(layers[i]), block = [];
155
169
  if (compact) {
156
170
  for (const n of rooms) {
157
- const label = `${mapCode(n)} ${labels.get(n.id)}`;
171
+ const label = `${codeLabel(n)} ${labels.get(n.id)}`;
158
172
  const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
159
173
  const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
160
174
  let suffix='';
@@ -169,7 +183,7 @@ function drawMap(view, width, session = {}) {
169
183
  } else {
170
184
  for (const codes of [true,false]) {
171
185
  const pieces = rooms.map(n => {
172
- const text = codes ? mapCode(n) : labels.get(n.id);
186
+ const text = codes ? codeLabel(n) : labels.get(n.id);
173
187
  return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
174
188
  });
175
189
  const placed = place(pieces,width);
@@ -272,7 +286,10 @@ function cut(text, width) {
272
286
  }
273
287
  export function createScreen(ctx) {
274
288
  let state, roomId, leaseId, connection, online = false, synced = false, rooms = [], loading = true, busy = false, closed = false;
275
- let tab = 0, selected = 'create', index = 0, modal, note = '', pending, columns = 80, height = 24, serverProtocol = 1, unsupported;
289
+ let ascension = 0;
290
+ const ascensionNames = ['基础', '精英增多', '疲惫旅人', '贫困', '紧束腰带', '进阶之灾', '通货膨胀', '稀缺', '坚韧敌人', '致命敌人', '双重首领'];
291
+ const ascensionDescriptions = ['无进阶修正', '地图精英配额增加', '古神恢复损失生命的 80%', '战斗与宝箱金币减少', '初始药水栏位减 1', '初始牌组加入进阶之灾', '删牌 100 起,每次增加 50', '稀有卡与升级卡概率降低', '怪物生命与防御等强化', '怪物攻击等强化', '第三幕连续两个不同首领'];
292
+ let tab = 0, selected = 'create', index = 0, modal, note = '', pending, columns = 80, height = 24, serverProtocol = undefined, unsupported;
276
293
  // Frame row -> what that row means, rebuilt by every render.
277
294
  let hits = [];
278
295
  let targeting;
@@ -294,15 +311,20 @@ export function createScreen(ctx) {
294
311
  const changed = () => { if (!closed && !ctx.signal.aborted) ctx.redraw(); };
295
312
  const errorText = e => e instanceof Error ? e.message : '游戏请求失败。';
296
313
  const me = () => state?.players.find(p => p.id === ctx.user.id);
297
- const activeTab = () => tabs[tab];
314
+ let characterConfirmed=false, mapOpen, mapReturnTab=0;
315
+ 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(); }
298
319
  const logItems = () => state.log.map((entry, i) => typeof entry === 'string' ? {id:String(i),label:entry} : {id:String(entry.id),label:entry.text});
299
320
  // A phase this build cannot render stays read only, so nothing is submitted.
300
321
  const canAct = () => !!state && online && synced && !busy && !pending && !unsupported;
301
322
  const cardItem = c => ({ id: c.id, label: `${c.name} [${c.cost}] ${c.description}`, card: c });
302
323
  function items() {
303
324
  if (modal) return modal.items;
304
- 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 ? ' · 版本不兼容' : ''}` }))];
325
+ if (!roomId) return [{ id: 'create', label: '创建合作房间(2–4 人)' }, {id:'solo',label:rooms.some(r=>r.mode==='solo') ? `继续单人攀登 · A${rooms.find(r=>r.mode==='solo').ascension ?? 0}` : '单人攀登 · 开始'}, {id:'ascension',label:`新局难度 · A${ascension} ${ascensionNames[ascension]}(全部开放)`}, ...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(' / ')} · A${r.ascension ?? 0} · ${r.players.length}/4 · ${phases[r.phase] ?? r.phase}${r.paused ? ' · 已暂停' : ''}${r.compatible === false ? ' · 版本不兼容' : ''}` }))];
305
326
  if (!state) return [];
327
+ if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
306
328
  if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
307
329
  if (activeTab() === 'relics') return state.self.relics.map(r => ({ id: r.id ?? r.key, label: `${r.name}:${r.description}` }));
308
330
  if (activeTab() === 'potions') return state.self.potions.map(p => ({ id: p.id, label: `${p.name}:${p.description}`, potion: p }));
@@ -351,17 +373,19 @@ export function createScreen(ctx) {
351
373
  : { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
352
374
  return rows;
353
375
  }
376
+ if(state.phase==='map' && !state.paused && state.mapView?.nodes?.length)return [{id:'openMap',label:'打开地图 M'}];
354
377
  return state.options;
355
378
  }
356
379
  function anchor() {
357
380
  const rows = items(), at = rows.findIndex(row => row.id === selected);
358
- index = at >= 0 ? at : Math.max(0, Math.min(index, rows.length - 1)); selected = rows[index]?.id;
381
+ index = at >= 0 ? at : Math.max(0, Math.min(Number.isFinite(index) ? index : 0, rows.length - 1)); selected = rows[index]?.id;
359
382
  }
360
383
  function accept(s) {
361
384
  // A phase this build does not know means the server moved ahead of the
362
385
  // extension: open it read only instead of refusing the whole state.
363
386
  const unknownPhase = typeof s?.phase === 'string' && !Object.hasOwn(phases, s.phase);
364
- if (!wellFormed(s, !unknownPhase) || s.self.id !== ctx.user.id || s.channelId !== ctx.channel.id || s.roomId !== roomId) throw new Error('游戏状态与客户端不兼容。');
387
+ if (s?.protocol !== PROTOCOL) { synced=false; note=VERSION_ERROR; changed(); throw new Error(VERSION_ERROR); }
388
+ if (!wellFormed(s, !unknownPhase) || s.self.id !== ctx.user.id || s.channelId !== ctx.channel.id || s.roomId !== roomId) { synced=false; throw new Error('游戏状态与客户端不兼容。'); }
365
389
  // An older snapshot must not clear the read-only guard before it is discarded:
366
390
  // check the revision first, then update anything derived from the state.
367
391
  if (state && s.revision < state.revision) return;
@@ -379,7 +403,7 @@ export function createScreen(ctx) {
379
403
  mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
380
404
  }
381
405
  if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
382
- modal = undefined; selected = undefined; index = 0; tab = 0;
406
+ modal = undefined; mapOpen=undefined; pileView=undefined; selected = undefined; index = 0; tab = 0;
383
407
  rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
384
408
  }
385
409
  if (s.leaseId) { leaseId = s.leaseId; synced = true; }
@@ -409,11 +433,12 @@ export function createScreen(ctx) {
409
433
  try { result = await ctx.request(`${BASE}?channelId=${ctx.channel.id}`); }
410
434
  catch (e) { if (e?.status === 404) throw new Error('服务器尚未安装或启用 Spire 后端模块。'); throw e; }
411
435
  if (closed) return;
412
- if (![1,2].includes(result?.protocol) || !Array.isArray(result.rooms)) throw new Error('服务器未启用兼容的游戏模块。');
436
+ if (result?.protocol !== PROTOCOL) { synced=false; serverProtocol=undefined; throw new Error(VERSION_ERROR); }
437
+ if (!Array.isArray(result.rooms)) throw new Error('游戏房间列表无效。');
413
438
  serverProtocol = result.protocol; rooms = result.rooms; anchor(); loading = false;
414
439
  }
415
440
  function connect(id) {
416
- connection?.close(); roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
441
+ connection?.close(); characterConfirmed=false;mapOpen=undefined; roomId = id; state = undefined; leaseId = undefined; synced = false; loading = true; tab = 0; selected = undefined;
417
442
  connection = ctx.connect(`/ws/games/spire/rooms/${id}`, accept, connected => {
418
443
  online = connected; synced = false;
419
444
  if (!connected) note = '连接中断,正在重连;操作已暂停。';
@@ -422,12 +447,13 @@ export function createScreen(ctx) {
422
447
  });
423
448
  }
424
449
  async function submit(action, retry = false) {
425
- if ((!retry && !canAct()) || !leaseId || !roomId) return;
450
+ if ((!retry && !canAct()) || !online || !synced || !leaseId || !roomId) return;
426
451
  await guard(async () => {
427
452
  if (!retry) pending = { actionId: randomUUID(), leaseId, action: { ...action, phaseId: action.phaseId ?? state.phaseId } };
428
453
  const envelope = { ...pending, leaseId };
429
454
  try {
430
455
  const response = await ctx.request(`${BASE}/${roomId}/actions`, 'POST', envelope);
456
+ if(envelope.action.type==='character'){characterConfirmed=true;mapOpen=undefined;}
431
457
  pending = undefined; note = ''; modal = undefined; accept(response);
432
458
  } catch (e) {
433
459
  // Unknown transport outcomes retain the operation id; R retries exactly
@@ -439,12 +465,34 @@ export function createScreen(ctx) {
439
465
  });
440
466
  }
441
467
  function choose(title, candidates, acceptChoice) {
442
- modal = { title, items: candidates, accept: acceptChoice, previousSelected: selected, previousIndex: index }; index = 0; selected = candidates[0]?.id; changed();
468
+ modal = { title, items: candidates, accept: acceptChoice, previousSelected: selected, previousIndex: index, previousPage: listPage }; listPage = 0; index = 0; selected = candidates[0]?.id; changed();
443
469
  }
444
470
  function deckChoice(mode, callback) {
445
471
  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);
446
472
  if (!cards.length) { note = '没有符合条件的牌。'; changed(); return; }
447
- choose('选择牌组中的牌', cards.map(cardItem), row => callback(row.id));
473
+ choose('选择牌组中的牌', cards.map(cardItem), row => {
474
+ if (mode === 'deckUpgrade') { void previewUpgrade(row.id, 'deck', callback); return; }
475
+ callback(row.id);
476
+ });
477
+ }
478
+ async function previewUpgrade(cardId, source, confirmUpgrade) {
479
+ if (!roomId || !canAct() || !cardId) return;
480
+ const originRoom=roomId, originPhase=state.phaseId, originRevision=state.revision;
481
+ await guard(async () => {
482
+ const body = await ctx.request(`${BASE}/${originRoom}/previews/upgrade`, 'POST', { cardId, source });
483
+ if(roomId!==originRoom || state?.phaseId!==originPhase || state?.revision!==originRevision)return;
484
+ const line = card => `${card.name} [${card.cost}] ${card.description}`;
485
+ const rows = [
486
+ { id:'before', label:`升级前:${line(body.before)}` },
487
+ { id:'after', label:`升级后:${line(body.after)}` },
488
+ { id:'back', label:'返回' },
489
+ ];
490
+ if (confirmUpgrade) rows.push({ id:'confirm', label:'确认升级' });
491
+ choose('升级预览', rows, answer => {
492
+ if (answer.id === 'confirm') confirmUpgrade(cardId);
493
+ else { modal=undefined; anchor(); changed(); }
494
+ });
495
+ });
448
496
  }
449
497
  function cardAction(c) {
450
498
  if (!canAct() || state.paused || me()?.done || me()?.hp <= 0) return;
@@ -456,13 +504,16 @@ export function createScreen(ctx) {
456
504
  else void submit(action);
457
505
  };
458
506
  const eligible = state.self.hand.filter(x => x.id !== c.id && (c.choice !== 'upgrade' || (!x.upgraded && !['status', 'curse', 'quest'].includes(x.kind))));
459
- if (c.choice && eligible.length) choose(c.choice === 'upgrade' ? '选择升级的手牌' : '选择消耗的手牌', eligible.map(cardItem), row => { action.choice = row.id; send(); });
507
+ if (c.choice && eligible.length) choose(c.choice === 'upgrade' ? '选择升级的手牌' : '选择消耗的手牌', eligible.map(cardItem), row => {
508
+ const commit=()=>{ action.choice = row.id; send(); };
509
+ if (c.choice === 'upgrade') void previewUpgrade(row.id, 'hand', commit); else commit();
510
+ });
460
511
  else send();
461
512
  }
462
513
  // The keyboard branches and clicks move the same state through these, so the
463
514
  // two input paths cannot drift apart.
464
- function switchTab(next) { tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
465
- function moveTo(next) { const list = items(); index = Math.max(0, Math.min(next, list.length - 1)); selected = list[index]?.id; changed(); }
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(); }
466
517
  function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
467
518
  function clickRow(id) {
468
519
  const list = items(), at = list.findIndex(row => row.id === id);
@@ -773,11 +824,92 @@ function mapHeader(width, view) {
773
824
  return hits.filter(hit => hit.row <= y && y < hit.row + hit.height && hit.col <= x && x < hit.col + hit.width)
774
825
  .sort((left, right) => left.width * left.height - right.width * right.height)[0];
775
826
  }
827
+ // The divination board. The server sends only the cells a player has looked
828
+ // at, so the client cannot draw what it has not earned and has no layout of
829
+ // its own to keep in step.
830
+ let sphereCursor = { x: 5, y: 5 };
831
+ function sphereView() { return state?.eventProgress?.sphere; }
832
+ function sphereGlyph(cell) {
833
+ if (!cell) return '·';
834
+ if (cell.item === 'Relic') return 'R';
835
+ if (cell.item === 'Potion') return 'P';
836
+ if (cell.item === 'CardReward') return 'C';
837
+ if (cell.item === 'Gold') return '$';
838
+ if (cell.item === 'Curse') return '!';
839
+ return 'o';
840
+ }
841
+ function sphereAt(view, x, y) {
842
+ return (view.cells ?? []).find(cell => cell.x === x && cell.y === y);
843
+ }
844
+ function sphereLines(view) {
845
+ const header = ' ' + Array.from({ length: view.width }, (_, x) => String(x % 10)).join(' ');
846
+ const lines = [header];
847
+ for (let y = 0; y < view.height; y += 1) {
848
+ const cells = [];
849
+ for (let x = 0; x < view.width; x += 1) {
850
+ const here = x === sphereCursor.x && y === sphereCursor.y;
851
+ const glyph = sphereGlyph(sphereAt(view, x, y));
852
+ cells.push(here ? `[${glyph}` : ` ${glyph}`);
853
+ }
854
+ lines.push(`${String(y).padStart(2, ' ')} ${cells.join('').slice(1)}`);
855
+ }
856
+ return lines;
857
+ }
858
+ function sphereMove(dx, dy) {
859
+ const view = sphereView(); if (!view) return;
860
+ sphereCursor = { x: Math.max(0, Math.min(view.width - 1, sphereCursor.x + dx)),
861
+ y: Math.max(0, Math.min(view.height - 1, sphereCursor.y + dy)) };
862
+ changed();
863
+ }
864
+ function sphereUse() {
865
+ const view = sphereView(); if (!view || view.finished) return;
866
+ void submit({ type: 'sphere', op: 'use', x: sphereCursor.x, y: sphereCursor.y });
867
+ }
868
+ function sphereTool() {
869
+ const view = sphereView(); if (!view || view.finished) return;
870
+ void submit({ type: 'sphere', op: 'tool', tool: view.tool === 'Big' ? 'Small' : 'Big' });
871
+ }
872
+ // Teammate detail. Read only, and paged on the server: the client asks for a
873
+ // page and shows what comes back, so it never holds a second copy of anyone's
874
+ // deck and never has to keep one in step.
875
+ async function inspectPlayer(id, page = 0) {
876
+ if (!roomId || !canAct()) return;
877
+ await guard(async () => {
878
+ const body = await ctx.request(`${BASE}/${roomId}/players/${id}/inspection?page=${page}`);
879
+ const rows = body.rows ?? [];
880
+ const first = body.total === 0 ? 0 : page * (body.pageSize ?? 40) + 1;
881
+ const last = page * (body.pageSize ?? 40) + rows.length;
882
+ const pages = Math.max(1, Math.ceil(body.total / (body.pageSize ?? 40)));
883
+ const previous = modal?.inspection ? {previousSelected:modal.previousSelected, previousIndex:modal.previousIndex, previousPage:modal.previousPage} : undefined;
884
+ choose(`${body.name} · ${first}-${last}/${body.total} · 第 ${page + 1}/${pages} 页`,
885
+ rows.map((row, i) => ({ id: `i${i}`, label: `${row.pile === 'status' ? powerNames[row.key] ?? row.key : row.name ?? row.key}${row.amount !== undefined ? ` ${row.amount}` : ''}${row.description ? `:${row.description}` : ''}${row.upgraded ? ' +' : ''}` })),
886
+ () => { anchor(); changed(); });
887
+ if(previous)Object.assign(modal,previous);
888
+ modal.inspection={id,page,pages,total:body.total};
889
+ changed();
890
+ });
891
+ }
892
+ function endTurnState() {
893
+ return state.endTurn;
894
+ }
895
+ // One key, three states. The server decides which applies and why, so the
896
+ // client never has to infer "the turn is resolving" from the phase.
776
897
  function endTurn() {
777
- if (modal || !canAct() || state?.phase !== 'combat' || state.paused || state.pendingChoice || state.pendingDecision) return;
778
- if (me()?.done || (me()?.hp ?? 0) <= 0) return;
898
+ if (modal || !canAct() || state?.phase !== 'combat' || state.paused) return;
899
+ const control = endTurnState();
900
+ if (control.canUndoEndTurn) { void submit({ type: 'undoEnd', turn: state.turn }); return; }
901
+ if (!control.canEndTurn) return;
779
902
  void submit({ type: 'end' });
780
903
  }
904
+ function endTurnLabel() {
905
+ const own = me();
906
+ if ((own?.hp ?? 0) <= 0) return '[已倒下]';
907
+ if (state?.phase !== 'combat') return '';
908
+ const control = endTurnState();
909
+ if (control.canUndoEndTurn) return '[E 撤销结束回合]';
910
+ if (control.canEndTurn) return state.paused ? '[等待继续]' : '[E 结束回合]';
911
+ return state.paused ? '[等待继续]' : `[${control.reason || '等待结算'}]`;
912
+ }
781
913
  function inspectEntity(id) {
782
914
  const enemy = (state.enemies ?? []).find(entry => entry.id === id);
783
915
  const player = (state.players ?? []).find(entry => String(entry.id) === id);
@@ -849,12 +981,15 @@ function mapHeader(width, view) {
849
981
  function confirm() {
850
982
  if (busy || pending) return;
851
983
  anchor(); const row = items()[index]; if (!row) return;
852
- if (modal) { const current = modal; modal = undefined; selected = current.previousSelected; index = current.previousIndex; current.accept(row); return; }
984
+ if (modal) { const current = modal; modal = undefined; selected = current.previousSelected; index = current.previousIndex; listPage = current.previousPage ?? 0; current.accept(row); return; }
853
985
  if (!roomId) {
986
+ if (row.id === 'ascension') { choose('进阶难度 · 效果逐级叠加 · 创建后固定', ascensionNames.map((name, level)=>({id:String(level), label:`A${level} · ${name}:${ascensionDescriptions[level]}${level===ascension ? ' ✓' : ''}`})), choice=>{ascension=Number(choice.id);changed();});return; }
854
987
  void guard(async () => {
988
+ if(serverProtocol!==PROTOCOL)throw new Error(VERSION_ERROR);
855
989
  if (row.id === 'create' || row.id === 'solo') {
856
- if (row.id === 'solo' && serverProtocol < 2) throw new Error('单人模式需要更新 Spire 后端模块。');
857
- const result = await ctx.request(BASE, 'POST', { channelId: ctx.channel.id, requestId: randomUUID(),...(serverProtocol >= 2 ? {mode:row.id==='solo'?'solo':'coop'} : {}) }); connect(result.id);
990
+ const existing = row.id === 'solo' && rooms.find(r=>r.mode==='solo');
991
+ if (existing) { if(existing.compatible===false)throw new Error('该存档需要对应版本的后端游戏模块。'); await ctx.request(`${BASE}/${existing.id}/join`, 'POST');connect(existing.id);return; }
992
+ const result = await ctx.request(BASE, 'POST', { ascension, channelId: ctx.channel.id, requestId: randomUUID(),mode:row.id==='solo'?'solo':'coop' }); connect(result.id);
858
993
  } else {
859
994
  if (row.room.compatible === false) throw new Error('该存档需要对应版本的后端游戏模块。');
860
995
  await ctx.request(`${BASE}/${row.id}/join`, 'POST'); connect(row.id);
@@ -862,6 +997,8 @@ function mapHeader(width, view) {
862
997
  }); return;
863
998
  }
864
999
  if (!canAct()) return;
1000
+ if(row.id==='keepCharacter'){characterConfirmed=true;mapOpen=undefined;selected=undefined;index=0;changed();return;}
1001
+ if(row.id==='openMap'){mapOpen=true;mapReturnTab=tab;tab=0;changed();return;}
865
1002
  if (tab === 0 && row.pick) {
866
1003
  const limit = state.pendingChoice?.maximum ?? state.pendingDecision?.maximum ?? 1;
867
1004
  if (picked.has(row.pick)) picked.delete(row.pick);
@@ -869,7 +1006,7 @@ function mapHeader(width, view) {
869
1006
  else note=`最多选择 ${limit} 张牌。`;
870
1007
  changed();return;
871
1008
  }
872
- if (tab === 0 && row.card) { cardAction(row.card); return; }
1009
+ if (tab === 0 && !pileView && row.card) { cardAction(row.card); return; }
873
1010
  if (activeTab() === 'potions' && row.potion) {
874
1011
  if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
875
1012
  if (row.potion.usage === 'Automatic') { note = '这瓶药水会自动生效,不能主动使用。'; changed(); return; }
@@ -997,7 +1134,7 @@ function mapHeader(width, view) {
997
1134
  // The drawing reads the view state and the party; this supplies both.
998
1135
  const mapLines = (view, width) => drawMap(view, width,
999
1136
  { 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);
1137
+ const mapNavigation = () => !modal && !targeting && !needsCharacterChoice() && tab===0 && (mapOpen??state?.phase==='map') && !state?.paused && !state.pendingChoice && !state.pendingDecision && Boolean(state.mapView?.nodes?.length);
1001
1138
  const screen = {
1002
1139
  render(width, rows) {
1003
1140
  columns = width; height = rows; hits = [];
@@ -1005,7 +1142,7 @@ function mapHeader(width, view) {
1005
1142
  const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
1006
1143
  if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
1007
1144
  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} · 合作房间`;
1145
+ 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} · 合作房间`;
1009
1146
  const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
1010
1147
  add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
1011
1148
  if(mapNavigation()) {
@@ -1022,9 +1159,9 @@ function mapHeader(width, view) {
1022
1159
  add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
1023
1160
  add(cut(mapDetailText(view,mapData(view)),width),'muted');
1024
1161
  add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
1025
- add(cut('↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览',width),'muted');
1162
+ add(cut(state.phase==='map'?'↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览':'↑↓←→ 查看节点 · 仅查看路线 · M 返回',width),'muted');
1026
1163
  mark({kind:'tab',advance:true});
1027
- add(cut('[面板 Tab] · 悬停查看 · Esc 返回 · R 重试',width),'muted');
1164
+ add(cut('[面板 Tab] · M 关闭地图 · 悬停查看',width),'muted');
1028
1165
  hits=hits.filter(hit=>hit.row<rows);
1029
1166
  return frame.slice(0,rows);
1030
1167
  }
@@ -1039,7 +1176,41 @@ function mapHeader(width, view) {
1039
1176
  const partyBlocks = party.map(p => allyBlock(p));
1040
1177
  const legalEnemy = enemy => Boolean(targeting && enemy && targeting.arena === 'enemy' && targetIds().includes(enemy.id));
1041
1178
  const legalAlly = id => Boolean(targeting && targeting.arena === 'ally' && targetIds().includes(id));
1042
- if (wideBattlefield()) {
1179
+ if(width>=68 && rows>=24 && !wideBattlefield()) {
1180
+ // Medium terminals keep the two teams side by side. Party members
1181
+ // stack in the left column; enemies page independently on the right.
1182
+ 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)));
1185
+ const perPage=Math.max(1,Math.floor(budget/room));
1186
+ const pages=Math.max(1,Math.ceil(alive.length/perPage));
1187
+ if(chosen>=0)enemyPage=Math.floor(chosen/perPage);
1188
+ enemyPage=Math.max(0,Math.min(pages-1,enemyPage??0));
1189
+ const shown=alive.slice(enemyPage*perPage,(enemyPage+1)*perPage);
1190
+ 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}`;
1193
+ const intent=arena==='enemy'?intentLines(creature.intent,creature,state).join(' · '):resourcesText(creature);
1194
+ const powers=powerEntries(creature).map(p=>p.text).join(' · ');
1195
+ const body=[name,health];
1196
+ if(room>=4)body.push(healthBar(creature.hp,creature.maxHp));
1197
+ if(room>=3)body.push(intent||powers||'无额外状态');
1198
+ if(room>=5)body.push(intent?powers:'');
1199
+ return body.map(line=>displayWidth(line)>span-2?cut(line,span-3)+'…':line);
1200
+ };
1201
+ const columns=[party.map(p=>({id:String(p.id),lines:compactBlock(p,'ally',leftWidth),legal:legalAlly(String(p.id))})),
1202
+ shown.map(e=>({id:e.id,lines:compactBlock(e,'enemy',rightWidth),legal:legalEnemy(e)}))];
1203
+ const startRow=frame.length,rendered=[[],[]];
1204
+ columns.forEach((entries,side)=>entries.forEach(entry=>{
1205
+ const top=rendered[side].length;
1206
+ const body=entry.legal?tint(entry.lines,targetTint(entry,targeting.arena),aimed(entry)):entry.lines;
1207
+ rendered[side].push(...body);
1208
+ while(rendered[side].length<top+room)rendered[side].push('');
1209
+ hits.push({kind:'entity',id:entry.id,row:startRow+top,col:side?leftWidth+3:0,width:side?rightWidth:leftWidth,height:room});
1210
+ }));
1211
+ 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()) {
1043
1214
  // The party column is as wide as what it prints (a fixed third used to
1044
1215
  // cut 增益/减益 short); the enemies share what is left.
1045
1216
  const natural = Math.max(MIN_BLOCK - 2, ...partyBlocks.flat().map(line => displayWidth(line)));
@@ -1098,13 +1269,20 @@ function mapHeader(width, view) {
1098
1269
  const usable = own?.energy ?? 0;
1099
1270
  const capacity = own?.energyCapacity ?? usable;
1100
1271
  const orbs = '●'.repeat(Math.max(0, usable)) + '○'.repeat(Math.max(0, capacity - usable));
1101
- const button = (own?.hp ?? 0) <= 0 ? '[已倒下]' : own?.done ? '[已结束回合]' : (!canAct() || state.paused) ? '[等待队友]' : '[E 结束回合]';
1272
+ const button = endTurnLabel();
1102
1273
  // §2: the Regent's stars are a resource of the same row as the energy.
1103
1274
  const stars = own?.character === 'regent' || (own?.stars ?? 0) > 0 ? `星星 ${own?.stars ?? 0}` : '';
1104
1275
  const energy = `能量 ${orbs ? `${orbs} ` : ''}${usable}/${capacity}${stars ? ` ${stars}` : ''}`;
1105
1276
  const buttonAt = Math.max(8, width - displayWidth(button) - 1);
1106
1277
  if (!modal) mark({ kind: 'endTurn', id: 'endTurn', col: buttonAt, width: displayWidth(button) });
1107
1278
  add(pad(energy, buttonAt) + button, 'title');
1279
+ 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]];
1282
+ const pileLabels=piles.map(([,key,name,count])=>width<60?`${key} ${name.slice(0,2)}(${count})`:`[${key} ${name}(${count})]`);
1283
+ const positions=[0,Math.floor((width-displayWidth(pileLabels[1]))/2),width-displayWidth(pileLabels[2])];
1284
+ if(!modal)for(let i=0;i<piles.length;i++)mark({kind:'pile',id:piles[i][0],col:positions[i],width:displayWidth(pileLabels[i])});
1285
+ add(place(pileLabels.map((text,i)=>({at:positions[i],text})),width).text,'muted');
1108
1286
  } else {
1109
1287
  add(`${state.event ?? ''} 金币 ${own?.gold ?? 0}`, 'muted');
1110
1288
  // §2: outside combat there is no battlefield, so the party, its health
@@ -1112,19 +1290,44 @@ function mapHeader(width, view) {
1112
1290
  for (const player of state.players ?? []) {
1113
1291
  const here = state.online?.includes(player.id);
1114
1292
  const flag = !here ? '离线' : state.paused || state.phase === 'lobby' ? (player.ready ? '已准备' : '') : player.done ? '已完成' : '';
1115
- add(cut(`${player.id === ctx.user.id ? '› ' : ' '}${player.name}${player.characterName ? ` · ${player.characterName}` : ''} ${hpText(player)} HP · 金币 ${player.gold ?? 0}${flag ? ` · ${flag}` : ''}${player.vote ? ` · 已投票 ${player.vote}` : ''}`, width), player.id === ctx.user.id ? 'selected' : 'text');
1293
+ const rowText = cut(`${player.id === ctx.user.id ? '› ' : ' '}${player.name}${player.characterName ? ` · ${player.characterName}` : ''} ${hpText(player)} HP · 金币 ${player.gold ?? 0}${flag ? ` · ${flag}` : ''}${player.vote ? ` · 已投票 ${player.vote}` : ''}`, width);
1294
+ // Clicking a teammate is the same act as pressing P on them. The
1295
+ // mark is taken before the line is pushed, while the row still is
1296
+ // the one it will land on.
1297
+ if (player.id !== ctx.user.id && !modal) mark({ kind: 'inspect', id: String(player.id), width: displayWidth(rowText) });
1298
+ add(rowText, player.id === ctx.user.id ? 'selected' : 'text');
1116
1299
  }
1117
1300
  if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
1301
+ const sphere = sphereView();
1302
+ if (sphere && !modal) {
1303
+ const left = sphere.divinations;
1304
+ add(`占卜剩余 ${left} 次 · 工具:${sphere.tool === 'Big' ? '大(3×3)' : '小(1 格)'} · 方向键移动,空格揭开,T 换工具`,
1305
+ left ? 'title' : 'muted');
1306
+ for (const line of sphereLines(sphere)) add(line, 'text');
1307
+ add('R 遗物 · P 药水 · C 卡牌奖励 · $ 金币 · ! 诅咒 · o 空 · · 未揭开', 'muted');
1308
+ }
1118
1309
 
1119
1310
  }
1120
1311
  const labels = tabs.map((key, i) => {
1121
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;
1122
1313
  const label = tabNames[key] + (count === undefined ? '' : `(${count})`);
1123
- return i === tab ? `[${label}]` : label;
1314
+ return !pileView && i === tab ? `[${label}]` : label;
1124
1315
  });
1125
1316
  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');
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
+ }
1128
1331
  if (modal?.detail) {
1129
1332
  const lines = ctx.wrap(modal.detail, Math.max(1, width - 2));
1130
1333
  const count = Math.max(1, Math.min(6, rows - frame.length - 9));
@@ -1135,14 +1338,15 @@ function mapHeader(width, view) {
1135
1338
  }
1136
1339
  } else add('频道成员可加入 · R 刷新房间列表', 'muted');
1137
1340
  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));
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;
1343
+ 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));
1141
1345
  pageSize = available;
1142
1346
  const maxPage = Math.max(0, Math.ceil(list.length / available) - 1);
1143
1347
  listPage = Math.max(0, Math.min(maxPage, listPage));
1144
1348
  const start = listPage * available;
1145
- if (!list.length && !loading) add(state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
1349
+ if (!list.length && !loading) add(pileView ? `${tabNames[pileView]}为空。Esc 返回战斗。` : state?.phase === 'combat' && !state.paused ? '没有手牌。按 E 结束回合。' : '等待队友,或按 Esc 返回聊天。', 'muted');
1146
1350
  // §E: a wide frame puts the shop's detail beside the goods; a narrow one
1147
1351
  // expands it below the list instead.
1148
1352
  const pane = state && state.phase === 'shop' && !modal && list[index]?.shop
@@ -1179,15 +1383,14 @@ function mapHeader(width, view) {
1179
1383
  }
1180
1384
  }
1181
1385
  if (maxPage > 0) add(`第 ${listPage + 1}/${maxPage + 1} 页 · PgUp/PgDn 翻页 · 共 ${list.length} 项`, 'muted');
1182
- while (frame.length < rows - 3 - (logLines ? logLines + 1 : 0)) add('');
1386
+ while (frame.length < rows - footerRows - (logLines ? logLines + 1 : 0)) add('');
1183
1387
  if (logLines) {
1184
1388
  add('战斗日志', 'muted');
1185
1389
  const recent = logItems().slice(-logLines);
1186
1390
  for (let i = 0; i < logLines; i++) add(recent[i]?.label ?? '', 'muted');
1187
1391
  }
1188
1392
  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');
1393
+ if(activeTab()==='potions' && !modal)add('D 丢弃药水', 'muted');
1191
1394
  const visible = Math.min(rows, frame.length); hits = hits.filter(hit => hit.row < visible);
1192
1395
  return frame.slice(0, rows);
1193
1396
  },
@@ -1210,6 +1413,11 @@ function mapHeader(width, view) {
1210
1413
  if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
1211
1414
  if (value === 'e') { cancelTargeting(); return; }
1212
1415
  }
1416
+ if(value==='m' && state?.mapView?.nodes?.length && !modal && !targeting && !state.paused && !needsCharacterChoice() && !state.pendingChoice && !state.pendingDecision){
1417
+ if(mapNavigation()){mapOpen=false;tab=mapReturnTab;}
1418
+ else{mapReturnTab=tab;tab=0;mapOpen=true;}
1419
+ changed();return;
1420
+ }
1213
1421
  if (mapNavigation()) {
1214
1422
  if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
1215
1423
  if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
@@ -1231,11 +1439,29 @@ function mapHeader(width, view) {
1231
1439
  return;
1232
1440
  }
1233
1441
  if(['return','enter'].includes(name)){
1234
- const row=items().find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
1442
+ const row=state.phase==='map' && state.options.find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
1235
1443
  if(row)void submit({...row.action,phaseId:state.phaseId});
1236
1444
  return;
1237
1445
  }
1238
1446
  }
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;}
1451
+ // The board takes the arrows before the option list does, or moving on
1452
+ // the grid would scroll the rows underneath it.
1453
+ if (columns>=40 && height>=20 && sphereView() && !sphereView().finished && tab===0 && !pileView && !modal && !targeting) {
1454
+ if (name === 'up') { sphereMove(0, -1); return; }
1455
+ if (name === 'down') { sphereMove(0, 1); return; }
1456
+ if (name === 'left') { sphereMove(-1, 0); return; }
1457
+ if (name === 'right') { sphereMove(1, 0); return; }
1458
+ if (value === 'h') { sphereMove(-1, 0); return; }
1459
+ if (value === 'l') { sphereMove(1, 0); return; }
1460
+ if (value === 'k') { sphereMove(0, -1); return; }
1461
+ if (value === 'j') { sphereMove(0, 1); return; }
1462
+ if (value === ' ' || name === 'space' || name === 'return' || name === 'enter') { sphereUse(); return; }
1463
+ if (value === 't') { sphereTool(); return; }
1464
+ }
1239
1465
  if (value === ' ' && !modal && !targeting) {
1240
1466
  const row = items()[index];
1241
1467
  if (row?.pick) {
@@ -1255,7 +1481,7 @@ function mapHeader(width, view) {
1255
1481
  return;
1256
1482
  }
1257
1483
  if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
1258
- if (modal) { selected = modal.previousSelected; index = modal.previousIndex; modal = undefined; anchor(); changed(); }
1484
+ if (modal) { selected = modal.previousSelected; index = modal.previousIndex; listPage = modal.previousPage ?? 0; modal = undefined; anchor(); changed(); }
1259
1485
  else ctx.closeScreen(); return;
1260
1486
  }
1261
1487
  if (columns < 40 || height < (mapNavigation()?12:20)) return;
@@ -1273,14 +1499,37 @@ function mapHeader(width, view) {
1273
1499
  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
1500
  }
1275
1501
  if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
1276
- if (value === 's' && !modal && state) { switchTab(tabs.indexOf('status')); return; }
1277
1502
  // The key and the button share one entry point.
1278
1503
  if (value === 'e' && !modal && !targeting) { endTurn(); return; }
1504
+ if (value === 'p' && !modal && !targeting && state) {
1505
+ const others = (state.players ?? []).filter(player => player.id !== ctx.user.id);
1506
+ if (!others.length) { note = '没有队友可查看。'; changed(); return; }
1507
+ if (others.length === 1) { void inspectPlayer(others[0].id); return; }
1508
+ choose('查看队友', others.map(player => ({ id: String(player.id), player: player.id, label: `${player.name} · ${player.characterName ?? ''}` })),
1509
+ row => { void inspectPlayer(row.player ?? Number(row.id)); });
1510
+ return;
1511
+ }
1512
+ if (value === 'u' && !modal && !targeting && state) {
1513
+ const row=items()[index];
1514
+ const cardId=row?.card?.id ?? row?.shop?.cardId ??
1515
+ (['reward','customReward'].includes(row?.action?.type) && !['skip','reroll','sacrifice'].includes(row.action.card) ? row.action.card : undefined);
1516
+ if (cardId) {
1517
+ const source=row?.shop ? 'shop' : ['reward','customReward'].includes(row?.action?.type) ? 'reward' : ['deck','draw','discard','exhaust'].includes(activeTab()) ? activeTab() : 'hand';
1518
+ void previewUpgrade(cardId,source);
1519
+ }
1520
+ else { note='当前项目没有可预览的升级。';changed(); }
1521
+ return;
1522
+ }
1279
1523
  if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
1280
1524
  if (value === 'd' && !modal && activeTab() === 'potions' && canAct()) {
1281
1525
  if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
1282
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;
1283
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
+ }
1284
1533
  if (modal?.detail && ['pageup','pagedown'].includes(name)) { pageDetail(name === 'pageup' ? -3 : 3); return; }
1285
1534
  if (name === 'pageup' || name === 'pagedown') {
1286
1535
  const pages = Math.max(1, Math.ceil(items().length / pageSize));
@@ -1320,11 +1569,14 @@ function mapHeader(width, view) {
1320
1569
  // §D: clicking a room you can travel to is the vote itself; any other
1321
1570
  // room is only looked at, and parks the view there.
1322
1571
  mapFocus = hit.id; mapAnchor = hit.id; mapScroll = undefined;
1323
- const row = items().find(row => row.action?.type === 'vote' && row.id === hit.id);
1572
+ const row = state.phase==='map' && state.options.find(row => row.action?.type === 'vote' && row.action.node === hit.id);
1324
1573
  if (row) { void submit({ ...row.action, phaseId: state.phaseId }); return; }
1325
1574
  changed();
1326
1575
  return;
1327
1576
  }
1577
+ if(hit?.kind==='enemyPage' && !targeting && !modal){enemyPage=(enemyPage+1)%hit.pages;changed();return;}
1578
+ if (hit?.kind === 'inspect') {void inspectPlayer(Number(hit.id));return;}
1579
+ if (hit?.kind === 'pile') {openPile(hit.id);return;}
1328
1580
  if (hit?.kind === 'shopBuy') {
1329
1581
  const row = items()[index];
1330
1582
  const price = row?.shop?.price ?? 0;
@@ -1376,4 +1628,4 @@ export async function run(context) {
1376
1628
 
1377
1629
 
1378
1630
  // Exported so a test can read a drawn act back the way a player does.
1379
- export { drawMap };
1631
+ export { drawMap };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkchat-extension-spire",
3
- "version": "0.1.8",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Optional cooperative card-climbing TUI for LinkChat",
6
6
  "license": "UNLICENSED",