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.
- package/README.md +11 -3
- package/index.js +197 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Spire for LinkChat — 0.
|
|
1
|
+
# Spire for LinkChat — 0.3.0
|
|
2
2
|
|
|
3
|
-
按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2
|
|
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.
|
|
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 &&
|
|
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
|
|
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:'单人攀登 ·
|
|
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 (
|
|
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 (
|
|
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 =>
|
|
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 => {
|
|
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
|
|
789
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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 ?
|
|
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 = (
|
|
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
|
-
|
|
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 };
|