linkchat-extension-spire 0.1.9 → 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 +11 -3
  2. package/index.js +197 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # Spire for LinkChat — 0.1.9
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.9.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
 
@@ -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,4 +1,6 @@
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: '已放弃' };
@@ -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); }
@@ -276,7 +286,10 @@ function cut(text, width) {
276
286
  }
277
287
  export function createScreen(ctx) {
278
288
  let state, roomId, leaseId, connection, online = false, synced = false, rooms = [], loading = true, busy = false, closed = false;
279
- 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;
280
293
  // Frame row -> what that row means, rebuilt by every render.
281
294
  let hits = [];
282
295
  let targeting;
@@ -309,7 +322,7 @@ export function createScreen(ctx) {
309
322
  const cardItem = c => ({ id: c.id, label: `${c.name} [${c.cost}] ${c.description}`, card: c });
310
323
  function items() {
311
324
  if (modal) return modal.items;
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 ? ' · 版本不兼容' : ''}` }))];
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 ? ' · 版本不兼容' : ''}` }))];
313
326
  if (!state) return [];
314
327
  if(needsCharacterChoice())return [{id:'keepCharacter',label:`使用当前角色:${me()?.characterName??me()?.character??'铁甲战士'}`},...state.options.filter(row=>row.action?.type==='character')];
315
328
  if (['deck','draw','discard','exhaust'].includes(activeTab())) return (state.self[activeTab()] ?? []).map(cardItem);
@@ -365,13 +378,14 @@ export function createScreen(ctx) {
365
378
  }
366
379
  function anchor() {
367
380
  const rows = items(), at = rows.findIndex(row => row.id === selected);
368
- 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;
369
382
  }
370
383
  function accept(s) {
371
384
  // A phase this build does not know means the server moved ahead of the
372
385
  // extension: open it read only instead of refusing the whole state.
373
386
  const unknownPhase = typeof s?.phase === 'string' && !Object.hasOwn(phases, s.phase);
374
- 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('游戏状态与客户端不兼容。'); }
375
389
  // An older snapshot must not clear the read-only guard before it is discarded:
376
390
  // check the revision first, then update anything derived from the state.
377
391
  if (state && s.revision < state.revision) return;
@@ -419,7 +433,8 @@ export function createScreen(ctx) {
419
433
  try { result = await ctx.request(`${BASE}?channelId=${ctx.channel.id}`); }
420
434
  catch (e) { if (e?.status === 404) throw new Error('服务器尚未安装或启用 Spire 后端模块。'); throw e; }
421
435
  if (closed) return;
422
- 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('游戏房间列表无效。');
423
438
  serverProtocol = result.protocol; rooms = result.rooms; anchor(); loading = false;
424
439
  }
425
440
  function connect(id) {
@@ -432,7 +447,7 @@ export function createScreen(ctx) {
432
447
  });
433
448
  }
434
449
  async function submit(action, retry = false) {
435
- if ((!retry && !canAct()) || !leaseId || !roomId) return;
450
+ if ((!retry && !canAct()) || !online || !synced || !leaseId || !roomId) return;
436
451
  await guard(async () => {
437
452
  if (!retry) pending = { actionId: randomUUID(), leaseId, action: { ...action, phaseId: action.phaseId ?? state.phaseId } };
438
453
  const envelope = { ...pending, leaseId };
@@ -450,12 +465,34 @@ export function createScreen(ctx) {
450
465
  });
451
466
  }
452
467
  function choose(title, candidates, acceptChoice) {
453
- 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();
454
469
  }
455
470
  function deckChoice(mode, callback) {
456
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);
457
472
  if (!cards.length) { note = '没有符合条件的牌。'; changed(); return; }
458
- 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
+ });
459
496
  }
460
497
  function cardAction(c) {
461
498
  if (!canAct() || state.paused || me()?.done || me()?.hp <= 0) return;
@@ -467,13 +504,16 @@ export function createScreen(ctx) {
467
504
  else void submit(action);
468
505
  };
469
506
  const eligible = state.self.hand.filter(x => x.id !== c.id && (c.choice !== 'upgrade' || (!x.upgraded && !['status', 'curse', 'quest'].includes(x.kind))));
470
- 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
+ });
471
511
  else send();
472
512
  }
473
513
  // The keyboard branches and clicks move the same state through these, so the
474
514
  // two input paths cannot drift apart.
475
515
  function switchTab(next) { pileView=undefined; tab = ((next % tabs.length) + tabs.length) % tabs.length; index = 0; selected = undefined; changed(); }
476
- function moveTo(next) { const list = items(); index = Math.max(0, Math.min(next, list.length - 1)); selected = list[index]?.id; 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(); }
477
517
  function pageDetail(delta) { modal.detailOffset = Math.max(0, (modal.detailOffset ?? 0) + delta); changed(); }
478
518
  function clickRow(id) {
479
519
  const list = items(), at = list.findIndex(row => row.id === id);
@@ -784,11 +824,92 @@ function mapHeader(width, view) {
784
824
  return hits.filter(hit => hit.row <= y && y < hit.row + hit.height && hit.col <= x && x < hit.col + hit.width)
785
825
  .sort((left, right) => left.width * left.height - right.width * right.height)[0];
786
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.
787
897
  function endTurn() {
788
- if (modal || !canAct() || state?.phase !== 'combat' || state.paused || state.pendingChoice || state.pendingDecision) return;
789
- 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;
790
902
  void submit({ type: 'end' });
791
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
+ }
792
913
  function inspectEntity(id) {
793
914
  const enemy = (state.enemies ?? []).find(entry => entry.id === id);
794
915
  const player = (state.players ?? []).find(entry => String(entry.id) === id);
@@ -860,12 +981,15 @@ function mapHeader(width, view) {
860
981
  function confirm() {
861
982
  if (busy || pending) return;
862
983
  anchor(); const row = items()[index]; if (!row) return;
863
- 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; }
864
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; }
865
987
  void guard(async () => {
988
+ if(serverProtocol!==PROTOCOL)throw new Error(VERSION_ERROR);
866
989
  if (row.id === 'create' || row.id === 'solo') {
867
- if (row.id === 'solo' && serverProtocol < 2) throw new Error('单人模式需要更新 Spire 后端模块。');
868
- 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);
869
993
  } else {
870
994
  if (row.room.compatible === false) throw new Error('该存档需要对应版本的后端游戏模块。');
871
995
  await ctx.request(`${BASE}/${row.id}/join`, 'POST'); connect(row.id);
@@ -1018,7 +1142,7 @@ function mapHeader(width, view) {
1018
1142
  const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
1019
1143
  if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
1020
1144
  const own = me();
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} · 合作房间`;
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} · 合作房间`;
1022
1146
  const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
1023
1147
  add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
1024
1148
  if(mapNavigation()) {
@@ -1145,7 +1269,7 @@ function mapHeader(width, view) {
1145
1269
  const usable = own?.energy ?? 0;
1146
1270
  const capacity = own?.energyCapacity ?? usable;
1147
1271
  const orbs = '●'.repeat(Math.max(0, usable)) + '○'.repeat(Math.max(0, capacity - usable));
1148
- const button = (own?.hp ?? 0) <= 0 ? '[已倒下]' : own?.done ? '[已结束回合]' : (!canAct() || state.paused) ? '[等待队友]' : '[E 结束回合]';
1272
+ const button = endTurnLabel();
1149
1273
  // §2: the Regent's stars are a resource of the same row as the energy.
1150
1274
  const stars = own?.character === 'regent' || (own?.stars ?? 0) > 0 ? `星星 ${own?.stars ?? 0}` : '';
1151
1275
  const energy = `能量 ${orbs ? `${orbs} ` : ''}${usable}/${capacity}${stars ? ` ${stars}` : ''}`;
@@ -1166,9 +1290,22 @@ function mapHeader(width, view) {
1166
1290
  for (const player of state.players ?? []) {
1167
1291
  const here = state.online?.includes(player.id);
1168
1292
  const flag = !here ? '离线' : state.paused || state.phase === 'lobby' ? (player.ready ? '已准备' : '') : player.done ? '已完成' : '';
1169
- 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');
1170
1299
  }
1171
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
+ }
1172
1309
 
1173
1310
  }
1174
1311
  const labels = tabs.map((key, i) => {
@@ -1311,6 +1448,20 @@ function mapHeader(width, view) {
1311
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;}
1312
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;}
1313
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
+ }
1314
1465
  if (value === ' ' && !modal && !targeting) {
1315
1466
  const row = items()[index];
1316
1467
  if (row?.pick) {
@@ -1330,7 +1481,7 @@ function mapHeader(width, view) {
1330
1481
  return;
1331
1482
  }
1332
1483
  if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
1333
- 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(); }
1334
1485
  else ctx.closeScreen(); return;
1335
1486
  }
1336
1487
  if (columns < 40 || height < (mapNavigation()?12:20)) return;
@@ -1350,11 +1501,35 @@ function mapHeader(width, view) {
1350
1501
  if (name === 'tab' && !modal && state) { switchTab(tab + (key.shift ? -1 : 1)); return; }
1351
1502
  // The key and the button share one entry point.
1352
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
+ }
1353
1523
  if (name === 'space' && !modal && tab===0 && state?.pendingChoice?.maximum>1) { confirm();return; }
1354
1524
  if (value === 'd' && !modal && activeTab() === 'potions' && canAct()) {
1355
1525
  if (state.self.potionsLocked) { note = '请先完成事件交易,当前不能使用或丢弃药水。'; changed(); return; }
1356
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;
1357
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
+ }
1358
1533
  if (modal?.detail && ['pageup','pagedown'].includes(name)) { pageDetail(name === 'pageup' ? -3 : 3); return; }
1359
1534
  if (name === 'pageup' || name === 'pagedown') {
1360
1535
  const pages = Math.max(1, Math.ceil(items().length / pageSize));
@@ -1400,6 +1575,7 @@ function mapHeader(width, view) {
1400
1575
  return;
1401
1576
  }
1402
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;}
1403
1579
  if (hit?.kind === 'pile') {openPile(hit.id);return;}
1404
1580
  if (hit?.kind === 'shopBuy') {
1405
1581
  const row = items()[index];
@@ -1452,4 +1628,4 @@ export async function run(context) {
1452
1628
 
1453
1629
 
1454
1630
  // Exported so a test can read a drawn act back the way a player does.
1455
- 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.9",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Optional cooperative card-climbing TUI for LinkChat",
6
6
  "license": "UNLICENSED",