@lizardon386/game-sdk 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/contract/client.d.ts +2 -0
- package/dist/contract/client.d.ts.map +1 -0
- package/dist/contract/client.js +2 -0
- package/dist/contract/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/kit/client/chat.d.ts +46 -0
- package/dist/kit/client/chat.d.ts.map +1 -0
- package/dist/kit/client/chat.js +183 -0
- package/dist/kit/client/chat.js.map +1 -0
- package/dist/kit/client/feed.d.ts +39 -0
- package/dist/kit/client/feed.d.ts.map +1 -0
- package/dist/kit/client/feed.js +157 -0
- package/dist/kit/client/feed.js.map +1 -0
- package/dist/kit/client/index.d.ts +6 -0
- package/dist/kit/client/index.d.ts.map +1 -0
- package/dist/kit/client/index.js +6 -0
- package/dist/kit/client/index.js.map +1 -0
- package/dist/kit/client/scoreboard.d.ts +68 -0
- package/dist/kit/client/scoreboard.d.ts.map +1 -0
- package/dist/kit/client/scoreboard.js +282 -0
- package/dist/kit/client/scoreboard.js.map +1 -0
- package/dist/kit/palette.d.ts +9 -0
- package/dist/kit/palette.d.ts.map +1 -0
- package/dist/kit/palette.js +38 -0
- package/dist/kit/palette.js.map +1 -0
- package/dist/kit/server/feed.d.ts +41 -0
- package/dist/kit/server/feed.d.ts.map +1 -0
- package/dist/kit/server/feed.js +210 -0
- package/dist/kit/server/feed.js.map +1 -0
- package/dist/kit/server/index.d.ts +3 -0
- package/dist/kit/server/index.d.ts.map +1 -0
- package/dist/kit/server/index.js +3 -0
- package/dist/kit/server/index.js.map +1 -0
- package/dist/kit/shared.d.ts +26 -0
- package/dist/kit/shared.d.ts.map +1 -0
- package/dist/kit/shared.js +8 -0
- package/dist/kit/shared.js.map +1 -0
- package/dist/node/pack.d.ts +2 -0
- package/dist/node/pack.d.ts.map +1 -0
- package/dist/node/pack.js +2 -0
- package/dist/node/pack.js.map +1 -0
- package/package.json +60 -4
- package/src/contract/client.ts +1 -0
- package/src/index.ts +45 -0
- package/src/kit/client/chat.ts +199 -0
- package/src/kit/client/feed.ts +170 -0
- package/src/kit/client/index.ts +33 -0
- package/src/kit/client/scoreboard.ts +333 -0
- package/src/kit/palette.ts +44 -0
- package/src/kit/server/feed.ts +221 -0
- package/src/kit/server/index.ts +2 -0
- package/src/kit/shared.ts +28 -0
- package/src/node/pack.ts +1 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { RoomPhase, SeatView } from '@lizardon386/core-abi';
|
|
2
|
+
import { seatColor } from '../palette.js';
|
|
3
|
+
|
|
4
|
+
export interface ScoreboardRow {
|
|
5
|
+
seatId: string;
|
|
6
|
+
values: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ScoreboardColumn {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
value?: string | ((row: ScoreboardRow) => unknown);
|
|
13
|
+
format?: (value: unknown, row: ScoreboardRow) => string;
|
|
14
|
+
/** 自定义单元格节点;返回 null 时退回 format。 */
|
|
15
|
+
render?: (value: unknown, row: ScoreboardRow) => Node | null;
|
|
16
|
+
sortValue?: (row: ScoreboardRow) => number | string;
|
|
17
|
+
sortable?: boolean;
|
|
18
|
+
align?: 'left' | 'center' | 'right';
|
|
19
|
+
width?: string | number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScoreboardRoom {
|
|
23
|
+
code: string;
|
|
24
|
+
phase: RoomPhase | 'none';
|
|
25
|
+
seats: SeatView[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ScoreboardSort {
|
|
29
|
+
key: string;
|
|
30
|
+
dir: 'asc' | 'desc';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScoreboardPanelOptions {
|
|
34
|
+
mount?: HTMLElement;
|
|
35
|
+
columns: readonly ScoreboardColumn[];
|
|
36
|
+
rows?: readonly ScoreboardRow[];
|
|
37
|
+
seats?: readonly SeatView[];
|
|
38
|
+
showTeam?: boolean;
|
|
39
|
+
showConnection?: boolean;
|
|
40
|
+
showHost?: boolean;
|
|
41
|
+
emptyText?: string;
|
|
42
|
+
sort?: ScoreboardSort;
|
|
43
|
+
/** 是否监听 window 的 bp:room;有 mount 时默认开。 */
|
|
44
|
+
listen?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeScoreboardRows(rows: readonly unknown[]): ScoreboardRow[] {
|
|
48
|
+
const out: ScoreboardRow[] = [];
|
|
49
|
+
for (const value of rows) {
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
51
|
+
const raw = value as Record<string, unknown>;
|
|
52
|
+
if (typeof raw.seatId !== 'string' || raw.seatId.length === 0) continue;
|
|
53
|
+
const values = raw.values;
|
|
54
|
+
if (values !== undefined && (typeof values !== 'object' || values === null || Array.isArray(values))) continue;
|
|
55
|
+
out.push({ seatId: raw.seatId, values: (values as Record<string, unknown> | undefined) ?? {} });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveColumnValue(column: ScoreboardColumn, row: ScoreboardRow): unknown {
|
|
61
|
+
if (typeof column.value === 'function') return column.value(row);
|
|
62
|
+
if (typeof column.value === 'string') return row.values[column.value];
|
|
63
|
+
return row.values[column.key];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatColumnValue(column: ScoreboardColumn, row: ScoreboardRow): string {
|
|
67
|
+
const value = resolveColumnValue(column, row);
|
|
68
|
+
if (column.format) return column.format(value, row);
|
|
69
|
+
return value === null || value === undefined ? '—' : String(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compareValues(a: unknown, b: unknown): number {
|
|
73
|
+
if (a === b) return 0;
|
|
74
|
+
if (a === null || a === undefined) return 1;
|
|
75
|
+
if (b === null || b === undefined) return -1;
|
|
76
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
77
|
+
return String(a).localeCompare(String(b), 'zh-CN');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sortScoreboardRows(
|
|
81
|
+
rows: readonly ScoreboardRow[],
|
|
82
|
+
columns: readonly ScoreboardColumn[],
|
|
83
|
+
sort: ScoreboardSort | undefined,
|
|
84
|
+
): ScoreboardRow[] {
|
|
85
|
+
if (!sort) return [...rows];
|
|
86
|
+
const column = columns.find((item) => item.key === sort.key);
|
|
87
|
+
if (!column) return [...rows];
|
|
88
|
+
const direction = sort.dir === 'desc' ? -1 : 1;
|
|
89
|
+
return [...rows].sort((a, b) => {
|
|
90
|
+
const left = column.sortValue ? column.sortValue(a) : resolveColumnValue(column, a);
|
|
91
|
+
const right = column.sortValue ? column.sortValue(b) : resolveColumnValue(column, b);
|
|
92
|
+
const leftMissing = left === null || left === undefined;
|
|
93
|
+
const rightMissing = right === null || right === undefined;
|
|
94
|
+
if (leftMissing || rightMissing) return leftMissing === rightMissing ? 0 : leftMissing ? 1 : -1;
|
|
95
|
+
return compareValues(left, right) * direction;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 座位表是主列表:没有游戏行的玩家也显示;有行但没座位的排在后面。 */
|
|
100
|
+
export function buildScoreboardRows(
|
|
101
|
+
rows: readonly ScoreboardRow[],
|
|
102
|
+
seats: readonly SeatView[],
|
|
103
|
+
): ScoreboardRow[] {
|
|
104
|
+
const bySeat = new Map(rows.map((row) => [row.seatId, row]));
|
|
105
|
+
const out: ScoreboardRow[] = [];
|
|
106
|
+
for (const seat of [...seats].sort((a, b) => a.slot - b.slot)) {
|
|
107
|
+
out.push(bySeat.get(seat.seatId) ?? { seatId: seat.seatId, values: {} });
|
|
108
|
+
bySeat.delete(seat.seatId);
|
|
109
|
+
}
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
if (bySeat.has(row.seatId)) out.push(row);
|
|
112
|
+
bySeat.delete(row.seatId);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function teamText(team: string | number | null): string {
|
|
118
|
+
return team === null || team === undefined ? '—' : String(team);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function connectionLabel(seat: SeatView | undefined): string {
|
|
122
|
+
if (!seat) return '—';
|
|
123
|
+
if (seat.role === 'ai') return '电脑';
|
|
124
|
+
if (seat.role === 'spectator') return '观战';
|
|
125
|
+
return seat.connected ? '在线' : '离线';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function styleCell(cell: HTMLTableCellElement, align: 'left' | 'center' | 'right' | undefined, width: string | number | undefined): void {
|
|
129
|
+
cell.style.padding = '6px 8px';
|
|
130
|
+
cell.style.borderTop = '1px solid rgba(127,127,127,.22)';
|
|
131
|
+
cell.style.textAlign = align ?? 'left';
|
|
132
|
+
if (width !== undefined) cell.style.width = typeof width === 'number' ? `${width}px` : width;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 记分板只提供壳:列和值完全由游戏定义,平台只补座位元数据。 */
|
|
136
|
+
export class ScoreboardPanel {
|
|
137
|
+
private readonly options: ScoreboardPanelOptions;
|
|
138
|
+
private readonly columns: ScoreboardColumn[];
|
|
139
|
+
private readonly root: HTMLDivElement | null = null;
|
|
140
|
+
private readonly head: HTMLTableSectionElement | null = null;
|
|
141
|
+
private readonly body: HTMLTableSectionElement | null = null;
|
|
142
|
+
private readonly onRoom: ((event: Event) => void) | null = null;
|
|
143
|
+
private rows: ScoreboardRow[] = [];
|
|
144
|
+
private seats: SeatView[] = [];
|
|
145
|
+
private sort: ScoreboardSort | undefined;
|
|
146
|
+
|
|
147
|
+
constructor(options: ScoreboardPanelOptions) {
|
|
148
|
+
this.options = options;
|
|
149
|
+
this.columns = options.columns.filter((column) => typeof column.key === 'string' && column.key.length > 0);
|
|
150
|
+
this.rows = normalizeScoreboardRows(options.rows ?? []);
|
|
151
|
+
this.seats = [...(options.seats ?? [])];
|
|
152
|
+
this.sort = options.sort;
|
|
153
|
+
if (options.mount) {
|
|
154
|
+
const doc = options.mount.ownerDocument;
|
|
155
|
+
const root = doc.createElement('div');
|
|
156
|
+
root.className = 'bp-scoreboard';
|
|
157
|
+
root.style.cssText = 'overflow-x:auto;font:13px/1.5 system-ui, "Microsoft YaHei UI", "Microsoft YaHei", sans-serif';
|
|
158
|
+
const table = doc.createElement('table');
|
|
159
|
+
table.className = 'bp-scoreboard-table';
|
|
160
|
+
table.setAttribute('aria-label', '记分板');
|
|
161
|
+
table.style.cssText = 'width:100%;border-collapse:collapse;min-width:360px';
|
|
162
|
+
const head = doc.createElement('thead');
|
|
163
|
+
const body = doc.createElement('tbody');
|
|
164
|
+
table.append(head, body);
|
|
165
|
+
root.append(table);
|
|
166
|
+
options.mount.replaceChildren(root);
|
|
167
|
+
this.root = root;
|
|
168
|
+
this.head = head;
|
|
169
|
+
this.body = body;
|
|
170
|
+
}
|
|
171
|
+
if (options.listen ?? Boolean(options.mount)) {
|
|
172
|
+
if (typeof window !== 'undefined') {
|
|
173
|
+
this.onRoom = (event: Event) => {
|
|
174
|
+
const room = (event as CustomEvent).detail as ScoreboardRoom | null;
|
|
175
|
+
this.seats = room && Array.isArray(room.seats) ? [...room.seats] : [];
|
|
176
|
+
this.render();
|
|
177
|
+
};
|
|
178
|
+
window.addEventListener('bp:room', this.onRoom);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
this.render();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setRows(rows: readonly ScoreboardRow[]): void {
|
|
185
|
+
this.rows = normalizeScoreboardRows(rows);
|
|
186
|
+
this.render();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setSeats(seats: readonly SeatView[]): void {
|
|
190
|
+
this.seats = [...seats];
|
|
191
|
+
this.render();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setRoom(room: ScoreboardRoom | null): void {
|
|
195
|
+
this.setSeats(room?.seats ?? []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
setSort(sort: ScoreboardSort | undefined): void {
|
|
199
|
+
this.sort = sort;
|
|
200
|
+
this.render();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
clear(): void {
|
|
204
|
+
this.rows = [];
|
|
205
|
+
this.seats = [];
|
|
206
|
+
this.render();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
destroy(): void {
|
|
210
|
+
if (typeof window !== 'undefined' && this.onRoom) window.removeEventListener('bp:room', this.onRoom);
|
|
211
|
+
this.options.mount?.replaceChildren();
|
|
212
|
+
this.rows = [];
|
|
213
|
+
this.seats = [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private toggleSort(key: string): void {
|
|
217
|
+
const current = this.sort;
|
|
218
|
+
this.sort = current?.key === key
|
|
219
|
+
? { key, dir: current.dir === 'asc' ? 'desc' : 'asc' }
|
|
220
|
+
: { key, dir: 'asc' };
|
|
221
|
+
this.render();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private render(): void {
|
|
225
|
+
const head = this.head;
|
|
226
|
+
const body = this.body;
|
|
227
|
+
if (!head || !body || !this.root) return;
|
|
228
|
+
const doc = this.root.ownerDocument;
|
|
229
|
+
const seats = new Map(this.seats.map((seat) => [seat.seatId, seat]));
|
|
230
|
+
const rows = sortScoreboardRows(buildScoreboardRows(this.rows, this.seats), this.columns, this.sort);
|
|
231
|
+
head.replaceChildren();
|
|
232
|
+
body.replaceChildren();
|
|
233
|
+
|
|
234
|
+
const headerRow = doc.createElement('tr');
|
|
235
|
+
const playerTh = doc.createElement('th');
|
|
236
|
+
playerTh.scope = 'col';
|
|
237
|
+
playerTh.textContent = '玩家';
|
|
238
|
+
styleCell(playerTh, 'left', undefined);
|
|
239
|
+
playerTh.style.textAlign = 'left';
|
|
240
|
+
headerRow.append(playerTh);
|
|
241
|
+
if (this.options.showTeam ?? true) {
|
|
242
|
+
const th = doc.createElement('th');
|
|
243
|
+
th.scope = 'col';
|
|
244
|
+
th.textContent = '队伍';
|
|
245
|
+
styleCell(th, 'left', undefined);
|
|
246
|
+
headerRow.append(th);
|
|
247
|
+
}
|
|
248
|
+
if (this.options.showConnection ?? true) {
|
|
249
|
+
const th = doc.createElement('th');
|
|
250
|
+
th.scope = 'col';
|
|
251
|
+
th.textContent = '连接';
|
|
252
|
+
styleCell(th, 'left', undefined);
|
|
253
|
+
headerRow.append(th);
|
|
254
|
+
}
|
|
255
|
+
for (const column of this.columns) {
|
|
256
|
+
const th = doc.createElement('th');
|
|
257
|
+
th.scope = 'col';
|
|
258
|
+
if (column.sortable !== false) {
|
|
259
|
+
const button = doc.createElement('button');
|
|
260
|
+
button.type = 'button';
|
|
261
|
+
button.className = 'bp-scoreboard-sort';
|
|
262
|
+
button.style.cssText = 'all:unset;cursor:pointer;font-weight:600';
|
|
263
|
+
const active = this.sort?.key === column.key;
|
|
264
|
+
button.textContent = `${column.label}${active ? (this.sort?.dir === 'asc' ? ' ↑' : ' ↓') : ''}`;
|
|
265
|
+
button.addEventListener('click', () => this.toggleSort(column.key));
|
|
266
|
+
th.append(button);
|
|
267
|
+
} else {
|
|
268
|
+
th.textContent = column.label;
|
|
269
|
+
}
|
|
270
|
+
th.style.fontWeight = '600';
|
|
271
|
+
styleCell(th, column.align, column.width);
|
|
272
|
+
headerRow.append(th);
|
|
273
|
+
}
|
|
274
|
+
head.append(headerRow);
|
|
275
|
+
|
|
276
|
+
if (rows.length === 0) {
|
|
277
|
+
const row = doc.createElement('tr');
|
|
278
|
+
const cell = doc.createElement('td');
|
|
279
|
+
cell.colSpan = 1 + (this.options.showTeam ?? true ? 1 : 0) + (this.options.showConnection ?? true ? 1 : 0) + this.columns.length;
|
|
280
|
+
cell.textContent = this.options.emptyText ?? '暂无数据';
|
|
281
|
+
cell.style.cssText = 'padding:12px 8px;opacity:.6;text-align:center';
|
|
282
|
+
row.append(cell);
|
|
283
|
+
body.append(row);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
const seat = seats.get(row.seatId);
|
|
289
|
+
const tr = doc.createElement('tr');
|
|
290
|
+
tr.dataset.seatId = row.seatId;
|
|
291
|
+
|
|
292
|
+
const player = doc.createElement('td');
|
|
293
|
+
const dot = doc.createElement('span');
|
|
294
|
+
dot.className = 'bp-scoreboard-color';
|
|
295
|
+
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:${seatColor({ seatId: row.seatId, slot: seat?.slot, team: seat?.team }).value}`;
|
|
296
|
+
const name = doc.createElement('span');
|
|
297
|
+
name.textContent = seat?.name ?? row.seatId;
|
|
298
|
+
player.append(dot, name);
|
|
299
|
+
if (seat?.isHost && (this.options.showHost ?? true)) {
|
|
300
|
+
const host = doc.createElement('span');
|
|
301
|
+
host.className = 'bp-scoreboard-host';
|
|
302
|
+
host.textContent = '房主';
|
|
303
|
+
host.style.cssText = 'margin-left:6px;padding:0 4px;border:1px solid currentColor;border-radius:4px;font-size:11px;opacity:.75';
|
|
304
|
+
player.append(host);
|
|
305
|
+
}
|
|
306
|
+
styleCell(player, 'left', undefined);
|
|
307
|
+
tr.append(player);
|
|
308
|
+
|
|
309
|
+
if (this.options.showTeam ?? true) {
|
|
310
|
+
const team = doc.createElement('td');
|
|
311
|
+
team.textContent = teamText(seat?.team ?? null);
|
|
312
|
+
styleCell(team, 'left', undefined);
|
|
313
|
+
tr.append(team);
|
|
314
|
+
}
|
|
315
|
+
if (this.options.showConnection ?? true) {
|
|
316
|
+
const connection = doc.createElement('td');
|
|
317
|
+
connection.textContent = connectionLabel(seat);
|
|
318
|
+
connection.style.opacity = seat && (seat.role === 'ai' || seat.connected) ? '1' : '0.6';
|
|
319
|
+
styleCell(connection, 'left', undefined);
|
|
320
|
+
tr.append(connection);
|
|
321
|
+
}
|
|
322
|
+
for (const column of this.columns) {
|
|
323
|
+
const cell = doc.createElement('td');
|
|
324
|
+
const rendered = column.render?.(resolveColumnValue(column, row), row) ?? null;
|
|
325
|
+
if (rendered && typeof rendered.nodeType === 'number') cell.append(rendered);
|
|
326
|
+
else cell.textContent = formatColumnValue(column, row);
|
|
327
|
+
styleCell(cell, column.align, column.width);
|
|
328
|
+
tr.append(cell);
|
|
329
|
+
}
|
|
330
|
+
body.append(tr);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { SeatLike } from './shared.js';
|
|
2
|
+
|
|
3
|
+
export interface SeatColor {
|
|
4
|
+
index: number;
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const PALETTE: ReadonlyArray<{ name: string; value: string }> = [
|
|
10
|
+
{ name: 'red', value: '#e05d5d' },
|
|
11
|
+
{ name: 'blue', value: '#5d9ce0' },
|
|
12
|
+
{ name: 'green', value: '#5dbe7a' },
|
|
13
|
+
{ name: 'yellow', value: '#d6b85d' },
|
|
14
|
+
{ name: 'purple', value: '#a98ae0' },
|
|
15
|
+
{ name: 'teal', value: '#5dc9c9' },
|
|
16
|
+
{ name: 'orange', value: '#e09a5d' },
|
|
17
|
+
{ name: 'pink', value: '#d98ab8' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function mod(value: number): number {
|
|
21
|
+
return ((value % PALETTE.length) + PALETTE.length) % PALETTE.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hashTeam(team: string): number {
|
|
25
|
+
let hash = 0;
|
|
26
|
+
for (let i = 0; i < team.length; i += 1) hash = (hash * 31 + team.charCodeAt(i)) | 0;
|
|
27
|
+
return hash;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 队伍优先,其次座位号;同一输入永远得到同一种颜色。 */
|
|
31
|
+
export function seatColor(seat: SeatLike): SeatColor {
|
|
32
|
+
let index: number;
|
|
33
|
+
if (typeof seat.team === 'number' && Number.isFinite(seat.team)) {
|
|
34
|
+
index = mod(Math.trunc(seat.team) - 1);
|
|
35
|
+
} else if (typeof seat.team === 'string' && seat.team.length > 0) {
|
|
36
|
+
index = mod(hashTeam(seat.team));
|
|
37
|
+
} else if (typeof seat.slot === 'number' && Number.isFinite(seat.slot)) {
|
|
38
|
+
index = mod(Math.trunc(seat.slot) - 1);
|
|
39
|
+
} else {
|
|
40
|
+
index = 0;
|
|
41
|
+
}
|
|
42
|
+
const color = PALETTE[index]!;
|
|
43
|
+
return { index, ...color };
|
|
44
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { Effect, ViewerId } from '@lizardon386/core-abi';
|
|
2
|
+
import { FEED_EVENT, FeedError, type FeedEntry, type FeedVisibility } from '../shared.js';
|
|
3
|
+
|
|
4
|
+
export interface FeedSeat {
|
|
5
|
+
seatId: string;
|
|
6
|
+
team: string | number | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FeedLogOptions {
|
|
10
|
+
max?: number;
|
|
11
|
+
maxText?: number;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
/** team 可见性用;返回当前所有座位及其队伍。 */
|
|
14
|
+
seatTeams?: () => ReadonlyArray<FeedSeat>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FeedEmitInput {
|
|
18
|
+
type: string;
|
|
19
|
+
text: string;
|
|
20
|
+
actor?: string;
|
|
21
|
+
target?: string;
|
|
22
|
+
data?: Record<string, unknown>;
|
|
23
|
+
visibility?: FeedVisibility;
|
|
24
|
+
at?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface StoredEntry extends FeedEntry {
|
|
28
|
+
visibility: FeedVisibility;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_TYPE_LENGTH = 64;
|
|
32
|
+
const MAX_DATA_JSON = 2_000;
|
|
33
|
+
|
|
34
|
+
function clamp(value: number | undefined, min: number, max: number, fallback: number): number {
|
|
35
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
36
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateVisibility(value: FeedVisibility | undefined): FeedVisibility {
|
|
40
|
+
if (value === undefined || value === 'all') return 'all';
|
|
41
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
42
|
+
throw new FeedError('事件可见性必须是 all、{ seatId } 或 { team }');
|
|
43
|
+
}
|
|
44
|
+
const keys = Object.keys(value);
|
|
45
|
+
if (keys.length !== 1) throw new FeedError('事件可见性只能包含 seatId 或 team');
|
|
46
|
+
if (keys[0] === 'seatId') {
|
|
47
|
+
const seatId = (value as { seatId?: unknown }).seatId;
|
|
48
|
+
if (typeof seatId !== 'string' || seatId.length === 0) throw new FeedError('事件 seatId 不合法');
|
|
49
|
+
return { seatId };
|
|
50
|
+
}
|
|
51
|
+
if (keys[0] === 'team') {
|
|
52
|
+
const team = (value as { team?: unknown }).team;
|
|
53
|
+
if (typeof team !== 'string' && (typeof team !== 'number' || !Number.isFinite(team))) {
|
|
54
|
+
throw new FeedError('事件 team 不合法');
|
|
55
|
+
}
|
|
56
|
+
return { team };
|
|
57
|
+
}
|
|
58
|
+
throw new FeedError('事件可见性只能包含 seatId 或 team');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function publicEntry(entry: StoredEntry): FeedEntry {
|
|
62
|
+
const copy: FeedEntry = {
|
|
63
|
+
seq: entry.seq,
|
|
64
|
+
at: entry.at,
|
|
65
|
+
type: entry.type,
|
|
66
|
+
text: entry.text,
|
|
67
|
+
};
|
|
68
|
+
if (entry.actor !== undefined) copy.actor = entry.actor;
|
|
69
|
+
if (entry.target !== undefined) copy.target = entry.target;
|
|
70
|
+
if (entry.data !== undefined) copy.data = entry.data;
|
|
71
|
+
return copy;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateEntry(value: unknown): StoredEntry | null {
|
|
75
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
76
|
+
const raw = value as Record<string, unknown>;
|
|
77
|
+
if (!Number.isSafeInteger(raw.seq) || (raw.seq as number) < 1) return null;
|
|
78
|
+
if (typeof raw.at !== 'number' || !Number.isFinite(raw.at)) return null;
|
|
79
|
+
if (typeof raw.type !== 'string' || raw.type.trim().length === 0 || raw.type.length > MAX_TYPE_LENGTH) return null;
|
|
80
|
+
if (typeof raw.text !== 'string' || raw.text.length === 0) return null;
|
|
81
|
+
if (raw.actor !== undefined && typeof raw.actor !== 'string') return null;
|
|
82
|
+
if (raw.target !== undefined && typeof raw.target !== 'string') return null;
|
|
83
|
+
if (raw.data !== undefined && (typeof raw.data !== 'object' || raw.data === null || Array.isArray(raw.data))) return null;
|
|
84
|
+
let visibility: FeedVisibility;
|
|
85
|
+
try {
|
|
86
|
+
visibility = validateVisibility(raw.visibility as FeedVisibility | undefined);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const entry: StoredEntry = {
|
|
91
|
+
seq: raw.seq as number,
|
|
92
|
+
at: raw.at,
|
|
93
|
+
type: raw.type.trim(),
|
|
94
|
+
text: raw.text,
|
|
95
|
+
visibility,
|
|
96
|
+
};
|
|
97
|
+
if (raw.actor !== undefined) entry.actor = raw.actor;
|
|
98
|
+
if (raw.target !== undefined) entry.target = raw.target;
|
|
99
|
+
if (raw.data !== undefined) entry.data = raw.data as Record<string, unknown>;
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 服务端事件流水:生成序号/时间、按可见性发 Effect、保留最近 N 条。 */
|
|
104
|
+
export class FeedLog {
|
|
105
|
+
private readonly max: number;
|
|
106
|
+
private readonly maxText: number;
|
|
107
|
+
private readonly now: () => number;
|
|
108
|
+
private readonly seatTeams: (() => ReadonlyArray<FeedSeat>) | null;
|
|
109
|
+
private entries: StoredEntry[] = [];
|
|
110
|
+
private nextSeq = 1;
|
|
111
|
+
private pending: Effect[] = [];
|
|
112
|
+
|
|
113
|
+
constructor(options: FeedLogOptions = {}) {
|
|
114
|
+
this.max = clamp(options.max, 1, 500, 100);
|
|
115
|
+
this.maxText = clamp(options.maxText, 1, 2_000, 200);
|
|
116
|
+
this.now = options.now ?? (() => Date.now());
|
|
117
|
+
this.seatTeams = options.seatTeams ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
emit(input: FeedEmitInput): FeedEntry {
|
|
121
|
+
const type = typeof input.type === 'string' ? input.type.trim() : '';
|
|
122
|
+
const text = typeof input.text === 'string' ? input.text.trim() : '';
|
|
123
|
+
if (!type || type.length > MAX_TYPE_LENGTH) throw new FeedError('事件 type 为空或过长');
|
|
124
|
+
if (!text) throw new FeedError('事件 text 不能为空');
|
|
125
|
+
const visibility = validateVisibility(input.visibility);
|
|
126
|
+
|
|
127
|
+
let recipients: string[] = [];
|
|
128
|
+
if (typeof visibility === 'object' && 'team' in visibility) {
|
|
129
|
+
if (!this.seatTeams) throw new FeedError('team 可见性需要 seatTeams 选项');
|
|
130
|
+
recipients = [...new Set(
|
|
131
|
+
this.seatTeams()
|
|
132
|
+
.filter((seat) => seat.team === visibility.team)
|
|
133
|
+
.map((seat) => seat.seatId),
|
|
134
|
+
)];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const at = typeof input.at === 'number' && Number.isFinite(input.at) ? input.at : this.now();
|
|
138
|
+
const entry: StoredEntry = {
|
|
139
|
+
seq: this.nextSeq,
|
|
140
|
+
at,
|
|
141
|
+
type,
|
|
142
|
+
text: text.slice(0, this.maxText),
|
|
143
|
+
visibility,
|
|
144
|
+
};
|
|
145
|
+
if (input.actor) entry.actor = input.actor;
|
|
146
|
+
if (input.target) entry.target = input.target;
|
|
147
|
+
if (input.data) {
|
|
148
|
+
const json = JSON.stringify(input.data);
|
|
149
|
+
if (json.length > MAX_DATA_JSON) throw new FeedError('事件 data 过大');
|
|
150
|
+
entry.data = input.data;
|
|
151
|
+
}
|
|
152
|
+
this.nextSeq += 1;
|
|
153
|
+
this.entries.push(entry);
|
|
154
|
+
if (this.entries.length > this.max) this.entries.splice(0, this.entries.length - this.max);
|
|
155
|
+
|
|
156
|
+
const payload = publicEntry(entry);
|
|
157
|
+
if (visibility === 'all') {
|
|
158
|
+
this.pending.push({ kind: 'broadcast', name: FEED_EVENT, payload });
|
|
159
|
+
} else if ('seatId' in visibility) {
|
|
160
|
+
this.pending.push({ kind: 'toSeat', seatId: visibility.seatId, name: FEED_EVENT, payload });
|
|
161
|
+
} else {
|
|
162
|
+
for (const seatId of recipients) {
|
|
163
|
+
this.pending.push({ kind: 'toSeat', seatId, name: FEED_EVENT, payload });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return payload;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 按视角裁剪历史;viewer 为 null 时只返回全员事件。 */
|
|
170
|
+
viewFor(viewer: ViewerId): FeedEntry[] {
|
|
171
|
+
const viewerTeam = viewer !== null && this.seatTeams
|
|
172
|
+
? this.seatTeams().find((seat) => seat.seatId === viewer)?.team ?? null
|
|
173
|
+
: null;
|
|
174
|
+
return this.entries
|
|
175
|
+
.filter((entry) => {
|
|
176
|
+
if (entry.visibility === 'all') return true;
|
|
177
|
+
if (viewer === null) return false;
|
|
178
|
+
if ('seatId' in entry.visibility) return entry.visibility.seatId === viewer;
|
|
179
|
+
return viewerTeam !== null && entry.visibility.team === viewerTeam;
|
|
180
|
+
})
|
|
181
|
+
.map(publicEntry);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
drainEffects(): Effect[] {
|
|
185
|
+
const effects = this.pending;
|
|
186
|
+
this.pending = [];
|
|
187
|
+
return effects;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
clear(): void {
|
|
191
|
+
this.entries = [];
|
|
192
|
+
this.pending = [];
|
|
193
|
+
this.nextSeq = 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
snapshot(): unknown {
|
|
197
|
+
return {
|
|
198
|
+
version: 1,
|
|
199
|
+
nextSeq: this.nextSeq,
|
|
200
|
+
entries: this.entries.map((entry) => ({ ...entry, data: entry.data ? { ...entry.data } : undefined })),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
restore(value: unknown): void {
|
|
205
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new FeedError('事件流快照不合法');
|
|
206
|
+
const raw = value as { version?: unknown; nextSeq?: unknown; entries?: unknown };
|
|
207
|
+
if (raw.version !== 1 || !Array.isArray(raw.entries)) throw new FeedError('事件流快照版本不支持');
|
|
208
|
+
const entries: StoredEntry[] = [];
|
|
209
|
+
for (const item of raw.entries) {
|
|
210
|
+
const entry = validateEntry(item);
|
|
211
|
+
if (!entry) throw new FeedError('事件流快照含坏记录');
|
|
212
|
+
entries.push(entry);
|
|
213
|
+
}
|
|
214
|
+
entries.sort((a, b) => a.seq - b.seq);
|
|
215
|
+
this.entries = entries.slice(-this.max);
|
|
216
|
+
const maxSeq = this.entries.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
217
|
+
const savedNext = typeof raw.nextSeq === 'number' && Number.isSafeInteger(raw.nextSeq) ? raw.nextSeq : 1;
|
|
218
|
+
this.nextSeq = Math.max(savedNext, maxSeq + 1, 1);
|
|
219
|
+
this.pending = [];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** 游戏事件流的一条公开记录:玩法文本由游戏填,平台只搬运。 */
|
|
2
|
+
export interface FeedEntry {
|
|
3
|
+
seq: number;
|
|
4
|
+
at: number;
|
|
5
|
+
type: string;
|
|
6
|
+
text: string;
|
|
7
|
+
actor?: string;
|
|
8
|
+
target?: string;
|
|
9
|
+
data?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 事件对谁可见;team 需要 FeedLog 提供 seatTeams。 */
|
|
13
|
+
export type FeedVisibility = 'all' | { seatId: string } | { team: string | number };
|
|
14
|
+
|
|
15
|
+
export const FEED_EVENT = 'bp.feed';
|
|
16
|
+
|
|
17
|
+
export class FeedError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'FeedError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SeatLike {
|
|
25
|
+
seatId: string;
|
|
26
|
+
slot?: number;
|
|
27
|
+
team?: string | number | null;
|
|
28
|
+
}
|
package/src/node/pack.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@lizardon386/pack-format';
|