linkchat-extension-spire 0.1.7 → 0.1.8

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 +4 -4
  2. package/index.js +170 -224
  3. package/package.json +2 -3
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # Spire for LinkChat — 0.1.7
1
+ # Spire for LinkChat — 0.1.8
2
2
 
3
- 按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘,见下),以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-2`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
3
+ 按需安装的频道合作卡牌爬塔 TUI。需要支持扩展 API 1 的 LinkChatCLI **0.7.0-beta.2 及以上**(旧宿主只支持键盘);路线图与目标的颜色要 **0.7.3 及以上**,更旧的宿主会把颜色剥离,以及管理员安装并启用 `linkchat-spire` 后端包;后端规则版本 `spire-tui-2`、内容版本 `0.1.0-alpha.2.dev17`,存档版本不一致时明确拒绝加载,不会静默升级进行中的局。
4
4
 
5
- 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.7.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
5
+ 离线包安装:`linkchat extension install spire --from /绝对路径/linkchat-extension-spire-0.1.8.tgz`;省略 `--from` 时从官方 registry 安装 `linkchat-extension-spire@latest`。安装后重新打开 LinkChat,输入 `/spire`。2–4 人同频道创建或加入房间,各自准备后开始。断线或退出会暂停整局;所有人重新准备后继续。
6
6
 
7
7
  ## 操作
8
8
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  **选择单体目标**:不再弹出目标名单。使用需要目标的牌或药水后,合法目标会加上边框,直接用键鼠选中战场上的那个实体(名称、血条、状态都属于同一目标):左右键循环、Enter 或单击提交、Esc 取消本地未提交的操作且不消耗任何东西。目标死亡、牌被消耗或阶段变化时会重新校验,点击不会因为列表重排落到别的身份上。战斗之外使用单体药水时仍给出目标列表。
12
12
 
13
- **地图**:按后端给出的真实节点与连线绘制,`●` 当前位置、`✓` 已走过、带编号的是可前往的房间、灰色为暂不可达。悬停或点击只查看房间类型与真实后继,未知房间始终显示为未知;点击可达节点只是高亮,Enter 或"确认前往"才提交路线投票(多人提交的是本人一票)。`[回到当前位置 C]`、`[查看全图 F]` 可点,滚轮查看地图其余部分。
13
+ **地图**:普通视图按真实七列格位纵向分层,起点在下、向上前进;每个房间保留编号(如 `05-C`)和类型。`●` 当前位置、`✓` 已走过、数字为当前可选路线;黄色强调选中房间、普通出边和目标,投票人数仍在房间名后显示。↑ 查看更高层、↓ 查看更低层,←/→ 浏览同层房间,数字键定位可选路线,Enter 对选中的可达房间提交本人路线投票;悬停只查看,单击可达房间即投票。详情显示带编号的真实后继,飞靴额外可达单独列出,不画成普通地图边。`C` 回到当前位置,`F` 切换紧凑连接表,滚轮向上查看更高层、向下查看更低层;窄屏或高度不足时自动显示连接表,每行保留来源和目标编号、类型。地图阶段不再显示底部节点列表;直接在地图选点,底部保留详情和操作提示,Tab 查看其他面板。地图视图最低支持 40 列、12 行;暂停和缺少地图数据时仍保留操作列表。未知房间始终显示为未知。
14
14
 
15
15
  **商店**:分类页签(卡牌 / 遗物 / 药水 / 删牌服务)+ 竖向商品列表 + 右侧详情(窄窗口改为列表下方的展开页)。详情显示当前实际价格、半价状态、购买后余额;买不起时显示还差多少并禁止提交,药水栏满时给出"管理药水栏"入口。选中商品后点击 `[购买]` 或按 Enter 即成交,不再二次确认。删牌服务会先给出价格,选完牌再确认费用与目标。`离开商店` 始终在列表里。`[`、`]` 切换分类。
16
16
 
package/index.js CHANGED
@@ -82,8 +82,6 @@ export const internalIntentKeys = {
82
82
  };
83
83
  const signed = value => `${value > 0 ? '+' : ''}${value}`;
84
84
  // --- act map (§D) -----------------------------------------------------------
85
- const MAP_CELL = 10;
86
- const FLIGHT_COLOUR = '\x1b[95m'; // a move only a WingedBoots charge allows
87
85
  function mapData(view) {
88
86
  const nodes = (view?.nodes ?? []).filter(node => node && typeof node.id === 'string');
89
87
  const byId = new Map(nodes.map(node => [node.id, node]));
@@ -122,202 +120,127 @@ const joinGlyph = directions => {
122
120
  const key = `${set.has('u') ? 'u' : ''}${set.has('d') ? 'd' : ''}${set.has('l') ? 'l' : ''}${set.has('r') ? 'r' : ''}`;
123
121
  return JOIN[key] ?? '─';
124
122
  };
125
- function mapLabel(view, data, node, full, options) {
126
- if (full) return node.id === view.current ? '●' : data.visited.has(node.id) ? '✓' : data.reachable.has(node.id) ? '◆' : '·';
123
+ const mapCode = node => `${String(node.row).padStart(2, '0')}-${String.fromCharCode(65 + node.col)}`;
124
+ function mapLabel(view, data, node, options) {
127
125
  const route = mapRouteIndex(node.id, options);
128
- const mark = node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : '';
129
- return `${mark}${route >= 0 ? route + 1 : ''}[${node.type}]`;
126
+ return `${node.id === view.current ? '● ' : data.visited.has(node.id) ? '✓ ' : ''}${route >= 0 ? route + 1 : ''}[${node.type}]`;
130
127
  }
131
- // Where can I go is the first question a map answers, so the state is a colour:
132
- // the position bright cyan, a room you can enter bright green, the rest grey.
133
- function mapPaint(view, data, node, label) {
134
- const colour = node.id === view.current ? '\x1b[1;96m' : data.reachable.has(node.id) ? '\x1b[92m' : '\x1b[90m';
128
+ function mapPaint(view, data, node, label, focus) {
129
+ const child = data.byId.get(focus)?.children?.includes(node.id);
130
+ const colour = node.id === focus || child ? '\x1b[1;93m' : node.id === view.current ? '\x1b[1;96m' : data.reachable.has(node.id) ? '\x1b[92m' : '\x1b[90m';
135
131
  return `${colour}${label}\x1b[0m`;
136
132
  }
137
- // A vote is a coloured digit after the room, so a party sees the tally on the map.
138
133
  function mapVotes(node, players) {
139
134
  const seats = (players ?? []).filter(player => player.vote === node.id).length;
140
135
  return seats ? { text: `\x1b[33m${seats}\x1b[0m`, width: 1 } : { text: '', width: 0 };
141
136
  }
142
- // One lane column per room that links forward. Two rooms whose spans overlap
143
- // cannot share a column; rooms whose spans are separated by a blank line can, so
144
- // a gap is only as wide as its busiest stretch.
145
- function mapLanes(data, layers, index, extra) {
146
- const plans = [];
147
- for (const parent of data.rows.get(layers[index]) ?? []) {
148
- const kids = (data.rows.get(layers[index + 1]) ?? []).filter(child => (parent.children ?? []).includes(child.id));
149
- const flights = (extra?.get(parent.id) ?? []).filter(id => !kids.some(child => child.id === id));
150
- if (!kids.length && !flights.length) continue;
151
- const targets = [...kids.map(kid => kid.col), ...flights.map(id => data.byId.get(id).col)].sort((left, right) => left - right);
152
- plans.push({ id: parent.id, line: parent.col, kids: targets,
153
- flight: new Set(flights),
154
- owns: new Set([...kids.map(kid => kid.id), ...flights]),
155
- top: Math.min(parent.col, ...targets), bottom: Math.max(parent.col, ...targets), lane: 0 });
156
- }
157
- // A room at line L draws a rail along line L. A visitor whose kid lands on L
158
- // must therefore sit to its right, or the visitor's rail would cross the
159
- // resident's lane -- and a crossing there reads as a move that does not exist.
160
- const resident = new Map(plans.map(plan => [plan.line, plan.id]));
161
- const before = new Map(plans.map(plan => [plan.id, new Set()]));
162
- for (const plan of plans) {
163
- for (const line of plan.kids) {
164
- const host = resident.get(line);
165
- if (host !== undefined && host !== plan.id) before.get(plan.id).add(host);
166
- }
167
- }
168
- const order = [];
169
- const pending = new Set(plans.map(plan => plan.id));
170
- while (pending.size) {
171
- const next = [...pending].filter(id => before.get(id).size === 0).sort()[0];
172
- if (next === undefined) break; // a cycle: no order removes it
173
- order.push(next);
174
- pending.delete(next);
175
- for (const id of pending) before.get(id).delete(next);
176
- }
177
- for (const id of pending) order.push(id);
178
- const rank = new Map(order.map((id, at) => [id, at]));
179
- const placed = [];
180
- for (const plan of [...plans].sort((left, right) => rank.get(left.id) - rank.get(right.id))) {
181
- let column = Math.max(-1, ...[...before.get(plan.id)].map(id => placed.find(entry => entry.id === id)?.lane ?? -1)) + 1;
182
- while (placed.some(entry => entry.lane === column && !(entry.bottom + 1 < plan.top || plan.bottom + 1 < entry.top))) column += 1;
183
- plan.lane = column;
184
- placed.push(plan);
185
- }
186
- return { plans, lanes: placed.length ? Math.max(...placed.map(entry => entry.lane)) + 1 : 0 };
187
- }
188
- function drawMap(view, width, session) {
189
- const data = mapData(view);
190
- const layers = [...data.rows.keys()].sort((left, right) => left - right);
191
- const options = session?.options ?? [];
192
- const full = session?.full ?? false;
193
- const labels = new Map(data.nodes.map(node => [node.id, mapLabel(view, data, node, full, options)]));
194
- const votes = new Map(data.nodes.map(node => [node.id, mapVotes(node, session?.players)]));
195
- const side = new Map(data.nodes.map(node => [node.id, displayWidth(labels.get(node.id)) + votes.get(node.id).width]));
196
- const widest = full ? Math.max(2, ...side.values()) : Math.max(6, ...side.values());
197
- // §D: while a WingedBoots has charges the server accepts any room in the next
198
- // layer, so those moves are drawn too — a plan must not miss a legal route.
199
- const extra = new Map();
200
- if (data.current) {
201
- const here = data.current.row;
202
- const flights = (view.reachable ?? []).filter(id => !(data.current.children ?? []).includes(id)
203
- && data.byId.get(id)?.row === here + 1);
204
- if (flights.length) extra.set(data.current.id, flights);
205
- }
206
- const gaps = [];
207
- for (let index = 0; index + 1 < layers.length; index++) gaps.push(mapLanes(data, layers, index, extra));
208
- const xs = [0];
209
- // One column is kept for the rail itself: a room whose label is the widest
210
- // still needs a dash between it and its lane, or the link starts in mid-air.
211
- for (let index = 0; index + 1 < layers.length; index++) xs.push(xs[index] + widest + gaps[index].lanes + 2);
212
- const roomWidth = index => Math.max(0, ...(data.rows.get(layers[index]) ?? []).map(node => side.get(node.id)));
213
- const anchor = data.byId.get(session?.anchor) ?? data.current;
214
- const home = Math.max(0, layers.indexOf(anchor ? anchor.row : layers[0]));
215
- // Only the layers on screen take width: the hidden ones must not push the map right.
216
- const fits = (start, span) => start + span - 1 < layers.length
217
- && xs[start + span - 1] - xs[start] + roomWidth(start + span - 1) <= width;
218
- const spanFrom = start => { let span = 1; while (fits(start, span + 1)) span += 1; return span; };
219
- const widestSpan = Math.max(...layers.map((_, start) => spanFrom(start)));
220
- const scroll = session?.scroll;
221
- const scrolled = scroll !== undefined && scroll !== null;
222
- let from = Math.max(0, Math.min(Math.max(0, layers.length - widestSpan),
223
- scrolled ? scroll : Math.max(0, home - Math.floor(widestSpan / 2))));
224
- let span = spanFrom(from);
225
- // Following the position, it must stay in view: shift the window back when the
226
- // widest span did not fit where it was centred. A deliberate scroll may leave it.
227
- if (!scrolled) {
228
- while (from > 0 && from + span <= home) { from -= 1; span = spanFrom(from); }
229
- if (from > home) { from = home; span = spanFrom(from); }
230
- }
231
- const shown = index => index >= from && index < from + span;
232
- const origin = xs[from];
233
- const at = index => xs[index] - origin;
234
- const slot = new Map(data.nodes.map(node => [node.id, node.col]));
235
- const height = Math.max(0, ...slot.values()) + 1;
236
- // A sparse canvas: a name, a lane and a rail may share a column, never a cell.
237
- const node = new Map();
238
- const rail = new Map();
239
- const cellOf = (line, col) => {
240
- const key = `${line}:${col}`;
241
- let part = rail.get(key);
242
- if (!part) { part = { line, col, v: null, vd: new Set(), h: new Map(), kid: new Map(), flight: false }; rail.set(key, part); }
243
- return part;
244
- };
245
- const add = (line, col, owner, directions, kid, flight) => {
246
- if (col < 0 || col >= width) return; // the window cuts the drawing
247
- const part = cellOf(line, col);
248
- for (const direction of directions) {
249
- if (direction === 'u' || direction === 'd') { if (part.v === null) part.v = owner; part.vd.add(direction); }
250
- else part.h.set(owner, (part.h.get(owner) ?? new Set()).add(direction));
251
- }
252
- if (kid !== undefined) part.kid.set(owner, kid);
253
- if (flight) part.flight = true;
254
- };
255
- for (let index = 0; index < layers.length; index++) {
256
- if (!shown(index)) continue;
257
- for (const room of data.rows.get(layers[index]) ?? []) {
258
- const label = `${mapPaint(view, data, room, labels.get(room.id))}${votes.get(room.id).text}`;
259
- node.set(`${slot.get(room.id)}:${at(index)}`, { room, label, plain: labels.get(room.id) });
260
- }
261
- }
262
- const targets = new Map(); // parent -> the rooms it links to
263
- const kidLines = new Map(); // parent -> the lines those rooms sit on
264
- for (let index = 0; index + 1 < layers.length; index++) {
265
- if (!shown(index) || !shown(index + 1)) continue;
266
- for (const plan of gaps[index].plans) {
267
- targets.set(plan.id, plan.owns);
268
- kidLines.set(plan.id, new Set(plan.kids));
269
- const lane = at(index) + widest + 1 + plan.lane;
270
- const begin = at(index) + side.get(plan.id);
271
- for (let x = begin; x < lane; x++) add(plan.line, x, plan.id, 'lr');
272
- for (let line = plan.top; line <= plan.bottom; line++) {
273
- const directions = [];
274
- if (line > plan.top) directions.push('u');
275
- if (line < plan.bottom) directions.push('d');
276
- if (line === plan.line) directions.push('l');
277
- if (plan.kids.includes(line)) directions.push('r');
278
- if (directions.length) add(line, lane, plan.id, directions);
137
+ function drawMap(view, width, session = {}) {
138
+ const data = mapData(view), layers = [...data.rows.keys()].sort((a,b) => a-b);
139
+ const focus = session.focus ?? view.current;
140
+ 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
+ const cols = Math.max(6, ...data.nodes.map(n => n.col)) + 1;
143
+ const unsafeGap = layers.some((layer,i)=>{
144
+ const rooms=data.rows.get(layer), next=data.rows.get(layers[i+1])??[];
145
+ const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
146
+ return edges.some(([a,b])=>b.row!==layers[i+1] || (Math.abs(a.col-b.col)>1 && rooms.length>1 && next.length>1)) ||
147
+ edges.some(([a,b])=>edges.some(([c,d])=>(a.col-c.col)*(b.col-d.col)<0));
148
+ });
149
+ const compact = session.full || cols * slot > width || (session.height??24)<7 || unsafeGap;
150
+ const blocks = [];
151
+ const cell = (n, col, text) => ({id:n.id, row:0, col, width:displayWidth(text), height:1});
152
+ const plainRow = text => ({text, cells:[], tone:'muted'});
153
+ for (let i=0; i<layers.length; i++) {
154
+ const rooms = data.rows.get(layers[i]), block = [];
155
+ if (compact) {
156
+ for (const n of rooms) {
157
+ const label = `${mapCode(n)} ${labels.get(n.id)}`;
158
+ const kids = (n.children ?? []).map(id => data.byId.get(id)).filter(Boolean);
159
+ const targets=kids.length ? kids.map(k=>`${mapCode(k)}[${k.type}]`) : ['终点'];
160
+ let suffix='';
161
+ const emit=()=>block.push({text:mapPaint(view,data,n,label,focus)+mapVotes(n,session.players).text+suffix,cells:[cell(n,0,label)],tone:'text'});
162
+ for(const target of targets){
163
+ const next=`${suffix?' ·':' →'} ${target}`;
164
+ if(suffix && displayWidth(label+suffix+next)+mapVotes(n,session.players).width>width){emit();suffix='';}
165
+ suffix+=`${suffix?' ·':' →'} ${target}`;
166
+ }
167
+ emit();
279
168
  }
280
- for (const line of plan.kids) {
281
- const kid = (data.rows.get(layers[index + 1]) ?? []).find(room => room.col === line);
282
- const flight = kid && plan.flight.has(kid.id);
283
- for (let x = lane + 1; x < at(index + 1); x++) add(line, x, plan.id, 'lr', kid?.id, flight);
169
+ } else {
170
+ for (const codes of [true,false]) {
171
+ const pieces = rooms.map(n => {
172
+ const text = codes ? mapCode(n) : labels.get(n.id);
173
+ return {at:n.col*slot, text:mapPaint(view,data,n,text,focus)+(codes?'':mapVotes(n,session.players).text), node:n, plain:text};
174
+ });
175
+ const placed = place(pieces,width);
176
+ block.push({text:placed.text,tone:'text',cells:placed.placed.map(e=>cell(e.node,e.from,e.plain))});
284
177
  }
285
- }
286
- }
287
- const lines = [];
288
- for (let line = 0; line < height; line++) {
289
- const row = [];
290
- for (const [key, part] of rail) {
291
- if (part.line !== line) continue;
292
- const directions = new Set();
293
- if (part.v !== null) {
294
- // A lane joins its own taps. A foreign rail crossing it is cut -- unless
295
- // both lead to the same room, where the join is simply true.
296
- for (const direction of part.vd) directions.add(direction);
297
- // A rail crossing a lane joins it: whatever the player follows, the run
298
- // stays on the room's own line, and every line on that line leads to the
299
- // one room this line reaches.
300
- for (const set of part.h.values()) for (const direction of set) directions.add(direction);
301
- } else {
302
- for (const set of part.h.values()) for (const direction of set) directions.add(direction);
178
+ if (i+1<layers.length) {
179
+ const ink = Array.from({length:3},()=>new Map());
180
+ const put=(y,x,dirs,owner)=>{
181
+ let c=ink[y].get(x); if(!c){c={dirs:new Set(),owners:new Set()};ink[y].set(x,c);}
182
+ for(const d of dirs)c.dirs.add(d);c.owners.add(owner);
183
+ };
184
+ const horizontal=(y,a,b,owner)=>{
185
+ for(let x=Math.min(a,b);x<=Math.max(a,b);x++)put(y,x,`${x>Math.min(a,b)?'l':''}${x<Math.max(a,b)?'r':''}`,owner);
186
+ };
187
+ const edges=rooms.flatMap(n=>(n.children??[]).map(id=>[n,data.byId.get(id)]).filter(([,kid])=>kid));
188
+ // Original noncrossing edges use separate midpoint tracks. Branches
189
+ // share the source row; merges share only the destination row.
190
+ for(const [n,kid] of edges){
191
+ const a=n.col*slot+2,b=kid.col*slot+2,m=Math.round((a+b)/2);
192
+ horizontal(0,a,m,n.id);put(0,a,'u',n.id);put(0,m,'d',n.id);
193
+ put(1,m,'ud',n.id);
194
+ horizontal(2,m,b,n.id);put(2,m,'u',n.id);put(2,b,'d',n.id);
195
+ }
196
+ for(const row of ink){
197
+ const pieces=[...row].map(([at,c])=>({at,text:`${c.owners.has(focus)?'\x1b[1;93m':'\x1b[90m'}${joinGlyph(c.dirs)}\x1b[0m`}));
198
+ block.push(plainRow(place(pieces,width).text));
199
+ }
303
200
  }
304
- if (!directions.size) continue;
305
- const glyph = joinGlyph(directions);
306
- row.push({ at: part.col, text: part.flight ? `${FLIGHT_COLOUR}${glyph}\x1b[0m` : glyph });
307
201
  }
308
- for (const [key, room] of node) {
309
- const [on, col] = key.split(':').map(Number);
310
- if (on === line) row.push({ at: col, text: room.label, node: room.room, plain: room.plain });
202
+ blocks.push(block);
203
+ }
204
+ const budget=Math.max(3,session.height??24);
205
+ const anchor=data.byId.get(session.anchor)??data.current;
206
+ const home=Math.max(0,layers.indexOf(anchor?.row));
207
+ let from=Math.max(0,Math.min(blocks.length-1,session.scroll??Math.max(0,home-1)));
208
+ let lines=[],span=0;
209
+ // Scroll offsets count from the bottom (the beginning of the act).
210
+ // Reverse layers, but keep same-layer rooms and wrapped targets in reading order.
211
+ if(compact){
212
+ const all=[...blocks].reverse().flat();
213
+ const focusId=session.anchor??anchor?.id;
214
+ const focusEnd=all.findLastIndex(line=>line.cells.some(c=>c.id===focusId));
215
+ const offset=Math.max(0,Math.min(Math.max(0,all.length-budget),session.scroll??(focusEnd>=0?all.length-focusEnd-1:0)));
216
+ const start=Math.max(0,all.length-budget-offset);
217
+ lines=all.slice(start,start+budget);
218
+ return {lines,from:offset,span:lines.length,total:all.length,height:lines.length,compact:true};
219
+ }
220
+ // Show the focused layer even if the preceding layer uses the whole budget.
221
+ if(session.scroll===undefined && from<home && blocks[from].length+2>budget)from=home;
222
+ for(let i=from;i<blocks.length;i++){
223
+ const last=i===blocks.length-1;
224
+ if(lines.length+blocks[i].length>budget){
225
+ if(lines.length+2<=budget){lines.push(...blocks[i].slice(0,2));span++;}
226
+ break;
311
227
  }
312
- const placed = place(row.sort((left, right) => left.at - right.at), width);
313
- const here = placed.placed.filter(entry => entry.node);
314
- const tone = here.some(entry => entry.node.id === view.current) ? 'selected'
315
- : here.some(entry => data.reachable.has(entry.node.id)) ? 'text' : 'muted';
316
- lines.push({ text: placed.text, tone,
317
- cells: here.map(entry => ({ id: entry.node.id, row: 0, col: entry.from, width: displayWidth(entry.plain ?? entry.text), height: 1 })) });
228
+ lines.push(...blocks[i]);span++;
229
+ if(last)break;
318
230
  }
319
- return { lines, from, span, total: layers.length, height,
320
- flight: (extra.get(data.current?.id) ?? []).length > 0 };
231
+ // Remove dangling connectors at the viewport boundary.
232
+ while(lines.length && !lines.at(-1).cells.length)lines.pop();
233
+ // Reflect the geometry vertically, including each connector's directions.
234
+ // Labels remain code-above-type; neither text nor ANSI escapes are mirrored.
235
+ const mirror={'┌':'└','└':'┌','┐':'┘','┘':'┐','┬':'┴','┴':'┬'};
236
+ lines.reverse();
237
+ for(let i=0;i<lines.length;i++){
238
+ if(lines[i].cells.length){
239
+ [lines[i],lines[i+1]]=[lines[i+1],lines[i]];
240
+ i++;
241
+ }else lines[i]={...lines[i],text:lines[i].text.replace(/[┌└┐┘┬┴]/g,ch=>mirror[ch])};
242
+ }
243
+ return {lines,from,span,total:layers.length,height:lines.length,compact:false};
321
244
  }
322
245
 
323
246
  // The package ships without dependencies, so the tab strip is measured here.
@@ -356,10 +279,11 @@ export function createScreen(ctx) {
356
279
  let enemyPage = 0; // the enemy block page the battlefield is showing
357
280
  let listPage = 0; // the 1-9/0 page of the item list
358
281
  let pageSize = 10; // how many items a page holds at the current size
282
+ let mapBudget = 10;
359
283
  let mapScroll; // the map row the view starts at (undefined follows the anchor)
360
284
  let mapAnchor; // the room the viewport is parked on (a click or 回到当前位置)
361
285
  let mapFocus; // the node being looked at on the map, never a submitted route
362
- let mapFull = false; // §D 查看全图: every row of the act, markers only
286
+ let mapFull = false; // compact adjacency overview, with room types retained
363
287
  let chain; // §G: the claim that follows this player's own treasure pick
364
288
  let rewardLog = new Map(); // §F reward id -> claimed / skipped; display only, never a receipt
365
289
  let rewardOpen; // §F the card reward whose candidates are expanded
@@ -427,14 +351,6 @@ export function createScreen(ctx) {
427
351
  : { id: 'rewardDone', label: state.players.length > 1 ? '奖励已结算 · 等待队友' : '奖励已结算 · 等待继续' });
428
352
  return rows;
429
353
  }
430
- if (state.phase === 'map' && !state.paused && Array.isArray(state.mapView?.nodes)) {
431
- // §D: the map only highlights; going there is this explicit row (or Enter
432
- // on the route itself).
433
- const rows = [...state.options];
434
- const route = rows.find(row => row.action?.type === 'vote' && (row.id === selected || row.id === mapFocus));
435
- rows.push({ id: 'mapConfirm', label: route ? `确认前往:${route.label}` : '确认前往(先选择一条路线)', action: route?.action });
436
- return rows;
437
- }
438
354
  return state.options;
439
355
  }
440
356
  function anchor() {
@@ -459,6 +375,9 @@ export function createScreen(ctx) {
459
375
  choiceReturn=undefined;
460
376
  }
461
377
  }
378
+ if (state?.mapView?.current !== s.mapView?.current || state?.act?.index !== s.act?.index) {
379
+ mapFocus=undefined; mapAnchor=undefined; mapScroll=undefined;
380
+ }
462
381
  if (state?.phaseId !== s.phaseId || state?.paused !== s.paused) {
463
382
  modal = undefined; selected = undefined; index = 0; tab = 0;
464
383
  rewardLog = new Map(); rewardOpen = undefined; rewardPick = new Map(); skipQueue = []; skipKnown = new Set();
@@ -713,7 +632,7 @@ export function createScreen(ctx) {
713
632
  }
714
633
  function mapHeader(width, view) {
715
634
  const left = `路线选择 · ${state?.act?.name ?? view.act?.name ?? ''}`.trim();
716
- const buttons = ['[回到当前位置 C]', '[查看全图 F]'];
635
+ const buttons = width<70 ? ['[当前位置 C]', '[总览 F]'] : ['[回到当前位置 C]', '[紧凑总览 F]'];
717
636
  const right = buttons.join(' ');
718
637
  const head = cut(left, Math.max(8, width - displayWidth(right) - 1));
719
638
  const out = pad(head, Math.max(8, width - displayWidth(right))) + right;
@@ -725,9 +644,10 @@ function mapHeader(width, view) {
725
644
  const node = data.byId.get(mapFocus ?? view.current);
726
645
  if (!node) return '悬停或点击节点查看房间类型与真实后继。';
727
646
  const successors = (node.children ?? []).map(id => data.byId.get(id)).filter(Boolean)
728
- .map(next => `[${next.type}]`).join(' ') || '无';
647
+ .map(next => `${mapCode(next)}[${next.type}]`).join(' ') || '无';
729
648
  const standing = node.id === view.current ? '当前位置' : data.reachable.has(node.id) ? '可前往' : data.visited.has(node.id) ? '已走过' : '暂不可达 · 仅查看';
730
- return `选中:${node.type} · ${standing} · 后继:${successors}`;
649
+ const extra = node.id === view.current ? (view.reachable ?? []).filter(id => !(node.children ?? []).includes(id)).map(id => data.byId.get(id)).filter(Boolean).map(n => `${mapCode(n)}[${n.type}]`).join(' ') : '';
650
+ return `选中:${node.type} · ${standing} · ${mapCode(node)} · 后继:${successors}${extra ? ` · 飞靴额外可达:${extra}` : ''}`;
731
651
  }
732
652
  // --- shop (§E) ---------------------------------------------------------------
733
653
  const SHOP_TABS = [['card', '卡牌'], ['relic', '遗物'], ['potion', '药水'], ['removal', '删牌服务']];
@@ -1076,17 +996,38 @@ function mapHeader(width, view) {
1076
996
  }
1077
997
  // The drawing reads the view state and the party; this supplies both.
1078
998
  const mapLines = (view, width) => drawMap(view, width,
1079
- { full: mapFull, scroll: mapScroll, anchor: mapAnchor, players: state?.players, options: state?.options });
999
+ { 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);
1080
1001
  const screen = {
1081
1002
  render(width, rows) {
1082
1003
  columns = width; height = rows; hits = [];
1083
1004
  const frame = [], add = (text, tone = 'text') => frame.push({ text, tone });
1084
1005
  const mark = hit => hits.push({ row: frame.length, col: 0, width: Infinity, height: 1, ...hit });
1085
- if (width < 40 || rows < 20) { add('请扩大终端至至少 40 列、20 行。', 'title'); add('Esc 返回聊天', 'muted'); return frame; }
1006
+ if (width < 40 || rows < (mapNavigation()?12:20)) { add(`请扩大终端至至少 40 列、${mapNavigation()?12:20} 行。`, 'title'); add('Esc 返回聊天', 'muted'); return frame; }
1086
1007
  const own = me();
1087
1008
  const heading = state ? `${state.act ? `第 ${state.act.index + 1} 幕 ${state.act.name} · ` : ''}第 ${state.floor} 层 · ${phases[state.phase]}${state.paused ? ' · 暂停' : ''}` : `Spire · ${ctx.channel.name} · 合作房间`;
1088
1009
  const status = state ? `金币 ${own?.gold ?? 0} · 药水 ${(own?.potions ?? []).length}/${own?.potionCapacity ?? 3} · ${state.online?.includes(ctx.user.id) ? '已连接' : '离线'}` : '';
1089
1010
  add(status ? pad(cut(heading, Math.max(8, width - displayWidth(status) - 2)), width - displayWidth(status)) + status : cut(heading, width), 'title');
1011
+ if(mapNavigation()) {
1012
+ const view=state.mapView, head=mapHeader(width,view), headerRow=frame.length;
1013
+ add(head.text,'muted');
1014
+ for(const button of head.buttons) hits.push({kind:button.id,id:button.id,row:headerRow,col:button.at,width:button.width,height:1});
1015
+ mapBudget=Math.max(3,rows-frame.length-5);
1016
+ const drawn=mapLines(view,width);
1017
+ for(const line of drawn.lines){
1018
+ const row=frame.length;add(line.text,line.tone);
1019
+ for(const cell of line.cells)hits.push({kind:'node',id:cell.id,row,col:cell.col,width:cell.width,height:1});
1020
+ }
1021
+ while(frame.length<rows-5)add('');
1022
+ add(cut(`本幕${drawn.compact?'连接表':'路线'} · ${drawn.from+1}–${Math.min(drawn.total,drawn.from+drawn.span)}/${drawn.total} · ↑ 前进 · 滚轮查看`,width),'muted');
1023
+ add(cut(mapDetailText(view,mapData(view)),width),'muted');
1024
+ add(cut(busy?'操作处理中…':pending?note:note||(!synced?'等待服务器确认状态…':''),width),pending?'error':'muted');
1025
+ add(cut('↑↓←→ 选节点 · Enter / 单击 投票 · C 定位 · F 总览',width),'muted');
1026
+ mark({kind:'tab',advance:true});
1027
+ add(cut('[面板 Tab] · 悬停查看 · Esc 返回 · R 重试',width),'muted');
1028
+ hits=hits.filter(hit=>hit.row<rows);
1029
+ return frame.slice(0,rows);
1030
+ }
1090
1031
  if (unsupported) add(`此扩展版本不认识该阶段「${unsupported}」,请在更新扩展后继续(当前只读)。`, 'error');
1091
1032
  if (targeting) add(cut(`正在使用:${targeting.label} · 点击目标或 ←/→ 选择 · Enter 确认 · Esc 取消`, width), 'selected');
1092
1033
  if (state) {
@@ -1174,24 +1115,7 @@ function mapHeader(width, view) {
1174
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');
1175
1116
  }
1176
1117
  if (['event', 'ending', 'victory'].includes(state.phase) && state.eventProgress?.description) add(state.eventProgress.description, 'muted');
1177
- if (state.phase === 'map' && Array.isArray(state.mapView?.nodes) && state.mapView.nodes.length) {
1178
- // §D: the real act map, drawn from the backend's nodes and edges only.
1179
- const head = mapHeader(width, state.mapView);
1180
- const headRow = frame.length;
1181
- add(head.text, 'muted');
1182
- for (const button of head.buttons) hits.push({ kind: button.id, id: button.id, row: headRow, col: button.at, width: button.width, height: 1 });
1183
- const drawn = mapLines(state.mapView, width);
1184
- for (const line of drawn.lines) {
1185
- const top = frame.length;
1186
- add(line.text, line.tone);
1187
- for (const cell of line.cells) hits.push({ kind: 'node', id: cell.id, row: top, col: cell.col, width: cell.width, height: 1 });
1188
- }
1189
- if (drawn.total > drawn.span) add(`本幕 ${drawn.total} 层 · 显示第 ${drawn.from + 1}–${Math.min(drawn.total, drawn.from + drawn.span)} 层 · 滚轮查看`, 'muted');
1190
- // A cramped frame spends its rows on the map itself: the legend and
1191
- // the hint are the first things to go, never the rooms.
1192
- add(mapDetailText(state.mapView, mapData(state.mapView)), 'muted');
1193
- if (rows >= 24) add(`图例:数字 可前往 · ● 当前位置 · ✓ 已走过 · 黄色数字 已投票人数 · 点击可前往的房间即投票${drawn.flight ? ' · 洋红连线 额外可达(下一层任意房间)' : ''}`, 'muted');
1194
- }
1118
+
1195
1119
  }
1196
1120
  const labels = tabs.map((key, i) => {
1197
1121
  const count = ['deck', 'draw', 'discard', 'exhaust'].includes(key) ? state.self[key]?.length ?? (key === 'draw' ? state.self.drawCount : key === 'exhaust' ? state.self.exhaustCount : 0) : undefined;
@@ -1286,9 +1210,31 @@ function mapHeader(width, view) {
1286
1210
  if (name === 'return' || name === 'enter') { submitTarget(targeting.id); return; }
1287
1211
  if (value === 'e') { cancelTargeting(); return; }
1288
1212
  }
1289
- if (!modal && state?.phase === 'map' && Array.isArray(state.mapView?.nodes)) {
1213
+ if (mapNavigation()) {
1290
1214
  if (value === 'c') { mapScroll = undefined; mapFocus = undefined; mapAnchor = undefined; mapFull = false; changed(); return; }
1291
- if (value === 'f') { mapFull = !mapFull; changed(); return; }
1215
+ if (value === 'f') { mapFull = !mapFull; mapScroll=undefined; changed(); return; }
1216
+ if(tab===0 && ['left','right','up','down'].includes(name)) {
1217
+ const data=mapData(state.mapView), here=data.byId.get(mapFocus)??data.current??data.nodes[0];
1218
+ const candidates=data.nodes.filter(n=> name==='left'? n.row===here.row&&n.col<here.col : name==='right'? n.row===here.row&&n.col>here.col : name==='up'?n.row>here.row:n.row<here.row);
1219
+ candidates.sort((a,b)=>Math.abs(a.row-here.row)-Math.abs(b.row-here.row)||Math.abs(a.col-here.col)-Math.abs(b.col-here.col));
1220
+ if(candidates[0]){mapFocus=candidates[0].id;mapAnchor=mapFocus;mapScroll=undefined;}
1221
+ changed();return;
1222
+ }
1223
+ if(['pageup','pagedown','home','end'].includes(name)){
1224
+ const drawn=mapLines(state.mapView,columns),max=Math.max(0,drawn.total-drawn.span);
1225
+ mapScroll=name==='home'?max:name==='end'?0:Math.max(0,Math.min(max,(mapScroll??drawn.from)+(name==='pageup'?1:-1)*Math.max(1,drawn.span-1)));
1226
+ changed();return;
1227
+ }
1228
+ if(/^[0-9]$/.test(value??'')){
1229
+ const routes=state.options.filter(row=>row.action?.type==='vote'),at=value==='0'?9:Number(value)-1;
1230
+ if(routes[at]){mapFocus=routes[at].action.node;mapAnchor=mapFocus;mapScroll=undefined;changed();}
1231
+ return;
1232
+ }
1233
+ if(['return','enter'].includes(name)){
1234
+ const row=items().find(row=>row.action?.type==='vote' && row.action.node===(mapFocus??state.mapView.current));
1235
+ if(row)void submit({...row.action,phaseId:state.phaseId});
1236
+ return;
1237
+ }
1292
1238
  }
1293
1239
  if (value === ' ' && !modal && !targeting) {
1294
1240
  const row = items()[index];
@@ -1305,14 +1251,14 @@ function mapHeader(width, view) {
1305
1251
  // never play a card by itself. Enter, or a click, is what acts.
1306
1252
  if (!modal && !targeting && /^[0-9]$/.test(value ?? '')) {
1307
1253
  const list = items(), at = listPage * pageSize + (value === '0' ? 9 : Number(value) - 1);
1308
- if (at < list.length) moveTo(at);
1254
+ if (at < list.length) { moveTo(at); if(state?.phase==='map' && tab===0){mapFocus=list[at].action?.node;mapAnchor=mapFocus;mapScroll=undefined;} }
1309
1255
  return;
1310
1256
  }
1311
1257
  if (name === 'escape' || key.ctrl && ['c', 'd'].includes(name)) {
1312
1258
  if (modal) { selected = modal.previousSelected; index = modal.previousIndex; modal = undefined; anchor(); changed(); }
1313
1259
  else ctx.closeScreen(); return;
1314
1260
  }
1315
- if (columns < 38 || height < 20) return;
1261
+ if (columns < 40 || height < (mapNavigation()?12:20)) return;
1316
1262
  if (value === '?' && !modal) {
1317
1263
  const row = items()[index];
1318
1264
  const lines = ctx.wrap(row?.label ?? '方向键选择,Enter 确认;全部玩家结束回合后敌人行动。退出或掉线会暂停整局。', Math.max(1, columns - 4));
@@ -1351,7 +1297,7 @@ function mapHeader(width, view) {
1351
1297
  if (name === 'return' || name === 'enter') confirm();
1352
1298
  },
1353
1299
  mouse(event) {
1354
- if (columns < 38 || height < 20) return;
1300
+ if (columns < 40 || height < (mapNavigation()?12:20)) return;
1355
1301
  if (event.action === 'move') {
1356
1302
  // Hover lights a row, or shows which target the pointer is on.
1357
1303
  const pointed = hitAt(event.x, event.y);
@@ -1402,10 +1348,10 @@ function mapHeader(width, view) {
1402
1348
  // An open detail pane pages first, exactly like PgUp/PgDn do.
1403
1349
  if (modal?.detail) { pageDetail(step * 3); return; }
1404
1350
  // §D: on the map the wheel looks around the act instead of moving the cursor.
1405
- if (state?.phase === 'map' && Array.isArray(state.mapView?.nodes)) {
1351
+ if (mapNavigation()) {
1406
1352
  const drawn = mapLines(state.mapView, columns);
1407
- mapScroll = Math.max(0, Math.min(Math.max(0, drawn.total - drawn.span), (mapScroll ?? drawn.from) + step));
1408
- mapFull = false; changed(); return;
1353
+ mapScroll = Math.max(0, Math.min(Math.max(0, drawn.total - drawn.span), (mapScroll ?? drawn.from) - step));
1354
+ changed(); return;
1409
1355
  }
1410
1356
  moveTo(index + step);
1411
1357
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkchat-extension-spire",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "Optional cooperative card-climbing TUI for LinkChat",
6
6
  "license": "UNLICENSED",
@@ -22,7 +22,6 @@
22
22
  "directory": "extensions/spire"
23
23
  },
24
24
  "publishConfig": {
25
- "access": "public",
26
- "tag": "beta"
25
+ "access": "public"
27
26
  }
28
27
  }