@crowdedkingdoms/crowdyjs 8.18.0 → 8.19.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 +7 -0
- package/dist/domains/marketplace.d.ts +7 -1
- package/dist/domains/marketplace.d.ts.map +1 -1
- package/dist/domains/marketplace.js +10 -1
- package/dist/domains/playerCompute.d.ts +7 -1
- package/dist/domains/playerCompute.d.ts.map +1 -1
- package/dist/domains/playerCompute.js +10 -1
- package/dist/generated/graphql.d.ts +117 -16
- package/dist/generated/graphql.d.ts.map +1 -1
- package/dist/generated/graphql.js +10 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kit/npcs.d.ts.map +1 -1
- package/dist/kit/social.d.ts.map +1 -1
- package/dist/live-coding/ide.d.ts +16 -0
- package/dist/live-coding/ide.d.ts.map +1 -0
- package/dist/live-coding/ide.js +399 -0
- package/package.json +10 -3
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { LiveCodingController, } from './live-coding-controller.js';
|
|
2
|
+
import { mountLiveCoding, } from './mount.js';
|
|
3
|
+
import { PLAYER_CODE_TEMPLATES, } from './templates.js';
|
|
4
|
+
let vscodeServicesPromise = null;
|
|
5
|
+
/**
|
|
6
|
+
* Lazy Monaco live-coding IDE. Monaco and the language-client stack stay out
|
|
7
|
+
* of the importing game's hot bundle until this async mount is called.
|
|
8
|
+
* Missing LSP configuration degrades to the dependency-free textarea panel.
|
|
9
|
+
*/
|
|
10
|
+
export async function mountLiveCodingIDE(el, options) {
|
|
11
|
+
const token = resolveToken(options.appToken);
|
|
12
|
+
if (!options.languageServiceUrl || !token) {
|
|
13
|
+
return mountLiveCoding(el, options);
|
|
14
|
+
}
|
|
15
|
+
if (options.editorWorkerFactory) {
|
|
16
|
+
globalThis.MonacoEnvironment = { getWorker: options.editorWorkerFactory };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const [monaco, languageClientModule, wsJsonRpc, vscodeWrapperModule, workerFactoryModule,] = await Promise.all([
|
|
20
|
+
import('@codingame/monaco-vscode-editor-api'),
|
|
21
|
+
import('monaco-languageclient'),
|
|
22
|
+
import('vscode-ws-jsonrpc'),
|
|
23
|
+
import('monaco-languageclient/vscodeApiWrapper'),
|
|
24
|
+
import('monaco-languageclient/workerFactory'),
|
|
25
|
+
]);
|
|
26
|
+
if (!vscodeServicesPromise) {
|
|
27
|
+
const wrapper = new vscodeWrapperModule.MonacoVscodeApiWrapper({
|
|
28
|
+
$type: 'classic',
|
|
29
|
+
viewsConfig: { $type: 'EditorService' },
|
|
30
|
+
userConfiguration: {
|
|
31
|
+
json: JSON.stringify({
|
|
32
|
+
'workbench.colorTheme': 'Default Dark Modern',
|
|
33
|
+
'editor.wordBasedSuggestions': 'off',
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
monacoWorkerFactory: workerFactoryModule.configureDefaultWorkerFactory,
|
|
37
|
+
});
|
|
38
|
+
vscodeServicesPromise = wrapper.start();
|
|
39
|
+
}
|
|
40
|
+
await vscodeServicesPromise;
|
|
41
|
+
monaco.languages.register({ id: 'rust', extensions: ['.rs'] });
|
|
42
|
+
installRustSyntaxHighlighting(monaco);
|
|
43
|
+
monaco.languages.register({ id: 'toml', extensions: ['.toml'] });
|
|
44
|
+
const templates = options.templates ?? PLAYER_CODE_TEMPLATES;
|
|
45
|
+
const root = document.createElement('div');
|
|
46
|
+
root.className = 'ck-live-coding ck-live-coding-ide';
|
|
47
|
+
root.style.cssText =
|
|
48
|
+
'display:grid;grid-template-rows:auto auto minmax(320px,1fr) auto auto;' +
|
|
49
|
+
'gap:6px;min-height:520px;';
|
|
50
|
+
const controls = document.createElement('div');
|
|
51
|
+
controls.className = 'ck-live-coding-controls';
|
|
52
|
+
const target = select(['server', 'client']);
|
|
53
|
+
const template = document.createElement('select');
|
|
54
|
+
const deploy = button('Deploy');
|
|
55
|
+
const draft = button('Deploy draft');
|
|
56
|
+
const stop = button('Stop');
|
|
57
|
+
controls.append(target, template, deploy, draft, stop);
|
|
58
|
+
const tabs = document.createElement('div');
|
|
59
|
+
tabs.className = 'ck-live-coding-tabs';
|
|
60
|
+
tabs.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap;';
|
|
61
|
+
const editorHost = document.createElement('div');
|
|
62
|
+
editorHost.className = 'ck-live-coding-monaco';
|
|
63
|
+
editorHost.style.cssText = 'min-height:320px;border:1px solid #333;';
|
|
64
|
+
const problems = document.createElement('div');
|
|
65
|
+
problems.className = 'ck-live-coding-problems';
|
|
66
|
+
const status = document.createElement('pre');
|
|
67
|
+
status.className = 'ck-live-coding-status';
|
|
68
|
+
const meter = document.createElement('div');
|
|
69
|
+
meter.className = 'ck-live-coding-meter';
|
|
70
|
+
root.append(controls, tabs, editorHost, problems, status, meter);
|
|
71
|
+
el.appendChild(root);
|
|
72
|
+
const controller = new LiveCodingController({
|
|
73
|
+
...options,
|
|
74
|
+
onStatus: (value) => renderStatus(status, meter, value),
|
|
75
|
+
});
|
|
76
|
+
const editor = monaco.editor.create(editorHost, {
|
|
77
|
+
automaticLayout: true,
|
|
78
|
+
minimap: { enabled: false },
|
|
79
|
+
theme: 'vs-dark',
|
|
80
|
+
fontSize: 14,
|
|
81
|
+
tabSize: 2,
|
|
82
|
+
});
|
|
83
|
+
let models = [];
|
|
84
|
+
let languageClient = null;
|
|
85
|
+
let socket = null;
|
|
86
|
+
let modelRootUri = 'file:///player-mod';
|
|
87
|
+
const sourceJson = () => JSON.stringify(Object.fromEntries(models.map(({ path, model }) => [path, model.getValue()])));
|
|
88
|
+
const showModel = (path) => {
|
|
89
|
+
const found = models.find((entry) => entry.path === path);
|
|
90
|
+
if (found)
|
|
91
|
+
editor.setModel(found.model);
|
|
92
|
+
for (const child of Array.from(tabs.children)) {
|
|
93
|
+
child.dataset.active =
|
|
94
|
+
child.dataset.path === path ? 'true' : 'false';
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const setFiles = (sourceFilesJson) => {
|
|
98
|
+
for (const entry of models)
|
|
99
|
+
entry.model.dispose();
|
|
100
|
+
models = [];
|
|
101
|
+
tabs.replaceChildren();
|
|
102
|
+
const files = parseFiles(sourceFilesJson);
|
|
103
|
+
for (const [path, content] of Object.entries(files)) {
|
|
104
|
+
const uri = monaco.Uri.parse(`${modelRootUri}/${path}`);
|
|
105
|
+
const model = monaco.editor.createModel(content, path.endsWith('.rs') ? 'rust' : path.endsWith('.toml') ? 'toml' : 'plaintext', uri);
|
|
106
|
+
models.push({ path, model });
|
|
107
|
+
const tab = button(path);
|
|
108
|
+
tab.dataset.path = path;
|
|
109
|
+
tab.addEventListener('click', () => showModel(path));
|
|
110
|
+
tabs.appendChild(tab);
|
|
111
|
+
}
|
|
112
|
+
const preferred = models.find((entry) => entry.path === 'src/lib.rs') ?? models[0];
|
|
113
|
+
if (preferred)
|
|
114
|
+
showModel(preferred.path);
|
|
115
|
+
};
|
|
116
|
+
const refreshTemplates = () => {
|
|
117
|
+
template.replaceChildren();
|
|
118
|
+
const matching = templates.filter((item) => item.target === target.value);
|
|
119
|
+
for (const item of matching) {
|
|
120
|
+
template.append(new Option(item.title, item.id));
|
|
121
|
+
}
|
|
122
|
+
if (matching[0])
|
|
123
|
+
setFiles(matching[0].sourceFilesJson);
|
|
124
|
+
};
|
|
125
|
+
target.addEventListener('change', refreshTemplates);
|
|
126
|
+
template.addEventListener('change', () => {
|
|
127
|
+
const selected = templates.find((item) => item.id === template.value);
|
|
128
|
+
if (selected)
|
|
129
|
+
setFiles(selected.sourceFilesJson);
|
|
130
|
+
});
|
|
131
|
+
const doDeploy = (draftMode) => {
|
|
132
|
+
void controller
|
|
133
|
+
.deploy({
|
|
134
|
+
name: options.moduleName ?? 'scratch-mod',
|
|
135
|
+
target: target.value,
|
|
136
|
+
sourceFilesJson: sourceJson(),
|
|
137
|
+
draft: draftMode || options.draftByDefault,
|
|
138
|
+
})
|
|
139
|
+
.catch((error) => {
|
|
140
|
+
status.textContent = `error: ${error.message}`;
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
deploy.addEventListener('click', () => doDeploy(false));
|
|
144
|
+
draft.addEventListener('click', () => doDeploy(true));
|
|
145
|
+
stop.addEventListener('click', () => controller.stop());
|
|
146
|
+
const markerSubscription = monaco.editor.onDidChangeMarkers(() => {
|
|
147
|
+
const markers = monaco.editor.getModelMarkers({});
|
|
148
|
+
const errors = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Error).length;
|
|
149
|
+
const warnings = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Warning).length;
|
|
150
|
+
problems.textContent = `${errors} error(s) · ${warnings} warning(s)`;
|
|
151
|
+
});
|
|
152
|
+
refreshTemplates();
|
|
153
|
+
const authenticated = await authenticatedSocket(options.languageServiceUrl, token, String(options.appId), sourceJson());
|
|
154
|
+
socket = authenticated.socket;
|
|
155
|
+
const initialSource = sourceJson();
|
|
156
|
+
modelRootUri = authenticated.workspaceUri.replace(/\/$/, '');
|
|
157
|
+
setFiles(initialSource);
|
|
158
|
+
const adapter = websocketAdapter(socket);
|
|
159
|
+
const reader = new wsJsonRpc.WebSocketMessageReader(adapter);
|
|
160
|
+
const writer = new wsJsonRpc.WebSocketMessageWriter(adapter);
|
|
161
|
+
languageClient = new languageClientModule.MonacoLanguageClient({
|
|
162
|
+
name: 'Crowdy Rust',
|
|
163
|
+
clientOptions: {
|
|
164
|
+
documentSelector: [{ language: 'rust' }, { language: 'toml' }],
|
|
165
|
+
workspaceFolder: {
|
|
166
|
+
uri: monaco.Uri.parse(modelRootUri),
|
|
167
|
+
name: 'player-mod',
|
|
168
|
+
index: 0,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
messageTransports: { reader, writer },
|
|
172
|
+
});
|
|
173
|
+
await languageClient.start();
|
|
174
|
+
void controller.refreshUsage().catch(() => { });
|
|
175
|
+
return {
|
|
176
|
+
controller,
|
|
177
|
+
destroy: () => {
|
|
178
|
+
markerSubscription.dispose();
|
|
179
|
+
controller.stop();
|
|
180
|
+
void languageClient?.stop().catch(() => { });
|
|
181
|
+
socket?.close();
|
|
182
|
+
editor.dispose();
|
|
183
|
+
for (const entry of models)
|
|
184
|
+
entry.model.dispose();
|
|
185
|
+
root.remove();
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
console.warn('Monaco live-coding IDE unavailable; using textarea fallback', error);
|
|
191
|
+
el.replaceChildren();
|
|
192
|
+
return mountLiveCoding(el, options);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function installRustSyntaxHighlighting(monaco) {
|
|
196
|
+
const keywords = new Set([
|
|
197
|
+
'as',
|
|
198
|
+
'async',
|
|
199
|
+
'await',
|
|
200
|
+
'break',
|
|
201
|
+
'const',
|
|
202
|
+
'continue',
|
|
203
|
+
'crate',
|
|
204
|
+
'dyn',
|
|
205
|
+
'else',
|
|
206
|
+
'enum',
|
|
207
|
+
'extern',
|
|
208
|
+
'false',
|
|
209
|
+
'fn',
|
|
210
|
+
'for',
|
|
211
|
+
'if',
|
|
212
|
+
'impl',
|
|
213
|
+
'in',
|
|
214
|
+
'let',
|
|
215
|
+
'loop',
|
|
216
|
+
'match',
|
|
217
|
+
'mod',
|
|
218
|
+
'move',
|
|
219
|
+
'mut',
|
|
220
|
+
'pub',
|
|
221
|
+
'ref',
|
|
222
|
+
'return',
|
|
223
|
+
'self',
|
|
224
|
+
'static',
|
|
225
|
+
'struct',
|
|
226
|
+
'super',
|
|
227
|
+
'trait',
|
|
228
|
+
'true',
|
|
229
|
+
'type',
|
|
230
|
+
'unsafe',
|
|
231
|
+
'use',
|
|
232
|
+
'where',
|
|
233
|
+
'while',
|
|
234
|
+
]);
|
|
235
|
+
const types = new Set([
|
|
236
|
+
'Self',
|
|
237
|
+
'String',
|
|
238
|
+
'Vec',
|
|
239
|
+
'Option',
|
|
240
|
+
'Result',
|
|
241
|
+
'bool',
|
|
242
|
+
'char',
|
|
243
|
+
'str',
|
|
244
|
+
'usize',
|
|
245
|
+
'isize',
|
|
246
|
+
'u8',
|
|
247
|
+
'u16',
|
|
248
|
+
'u32',
|
|
249
|
+
'u64',
|
|
250
|
+
'u128',
|
|
251
|
+
'i8',
|
|
252
|
+
'i16',
|
|
253
|
+
'i32',
|
|
254
|
+
'i64',
|
|
255
|
+
'i128',
|
|
256
|
+
'f32',
|
|
257
|
+
'f64',
|
|
258
|
+
]);
|
|
259
|
+
const state = {
|
|
260
|
+
clone() {
|
|
261
|
+
return this;
|
|
262
|
+
},
|
|
263
|
+
equals(other) {
|
|
264
|
+
return other === this;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
monaco.languages.setTokensProvider('rust', {
|
|
268
|
+
getInitialState: () => state,
|
|
269
|
+
tokenize: (line, currentState) => {
|
|
270
|
+
const tokens = [];
|
|
271
|
+
let index = 0;
|
|
272
|
+
while (index < line.length) {
|
|
273
|
+
const rest = line.slice(index);
|
|
274
|
+
let match;
|
|
275
|
+
if ((match = rest.match(/^\s+/))) {
|
|
276
|
+
tokens.push({ startIndex: index, scopes: '' });
|
|
277
|
+
}
|
|
278
|
+
else if ((match = rest.match(/^\/\/.*$/))) {
|
|
279
|
+
tokens.push({ startIndex: index, scopes: 'comment.rust' });
|
|
280
|
+
}
|
|
281
|
+
else if ((match = rest.match(/^\/\*.*?(?:\*\/|$)/))) {
|
|
282
|
+
tokens.push({ startIndex: index, scopes: 'comment.rust' });
|
|
283
|
+
}
|
|
284
|
+
else if ((match = rest.match(/^"(?:\\.|[^"\\])*"?/))) {
|
|
285
|
+
tokens.push({ startIndex: index, scopes: 'string.rust' });
|
|
286
|
+
}
|
|
287
|
+
else if ((match = rest.match(/^(?:0x[\da-fA-F_]+|\d[\d_]*)/))) {
|
|
288
|
+
tokens.push({ startIndex: index, scopes: 'number.rust' });
|
|
289
|
+
}
|
|
290
|
+
else if ((match = rest.match(/^[A-Za-z_]\w*!/))) {
|
|
291
|
+
tokens.push({ startIndex: index, scopes: 'macro.rust' });
|
|
292
|
+
}
|
|
293
|
+
else if ((match = rest.match(/^[A-Za-z_]\w*/))) {
|
|
294
|
+
const word = match[0];
|
|
295
|
+
tokens.push({
|
|
296
|
+
startIndex: index,
|
|
297
|
+
scopes: keywords.has(word)
|
|
298
|
+
? 'keyword.rust'
|
|
299
|
+
: types.has(word)
|
|
300
|
+
? 'type.rust'
|
|
301
|
+
: 'identifier.rust',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
match = rest.match(/^./);
|
|
306
|
+
tokens.push({ startIndex: index, scopes: 'delimiter.rust' });
|
|
307
|
+
}
|
|
308
|
+
index += match?.[0].length ?? 1;
|
|
309
|
+
}
|
|
310
|
+
return { tokens, endState: currentState };
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function resolveToken(token) {
|
|
315
|
+
const value = typeof token === 'function' ? token() : token;
|
|
316
|
+
return value && value.length > 0 ? value : null;
|
|
317
|
+
}
|
|
318
|
+
function parseFiles(sourceFilesJson) {
|
|
319
|
+
const parsed = JSON.parse(sourceFilesJson);
|
|
320
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
321
|
+
throw new Error('sourceFilesJson must be an object');
|
|
322
|
+
}
|
|
323
|
+
return Object.fromEntries(Object.entries(parsed).map(([path, value]) => {
|
|
324
|
+
if (typeof value !== 'string')
|
|
325
|
+
throw new Error(`Invalid source file ${path}`);
|
|
326
|
+
return [path, value];
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
async function authenticatedSocket(url, token, appId, sourceFilesJson) {
|
|
330
|
+
const socket = new WebSocket(url);
|
|
331
|
+
let workspaceUri = 'file:///player-mod';
|
|
332
|
+
await new Promise((resolve, reject) => {
|
|
333
|
+
const timeout = setTimeout(() => reject(new Error('language service timeout')), 10000);
|
|
334
|
+
socket.addEventListener('open', () => {
|
|
335
|
+
socket.send(JSON.stringify({
|
|
336
|
+
type: 'authenticate',
|
|
337
|
+
token,
|
|
338
|
+
appId,
|
|
339
|
+
sourceFilesJson,
|
|
340
|
+
}));
|
|
341
|
+
}, { once: true });
|
|
342
|
+
socket.addEventListener('message', (event) => {
|
|
343
|
+
try {
|
|
344
|
+
const message = JSON.parse(String(event.data));
|
|
345
|
+
if (message.type !== 'ready' || !message.workspaceUri) {
|
|
346
|
+
throw new Error('language service refused session');
|
|
347
|
+
}
|
|
348
|
+
workspaceUri = message.workspaceUri;
|
|
349
|
+
clearTimeout(timeout);
|
|
350
|
+
resolve();
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
clearTimeout(timeout);
|
|
354
|
+
reject(error);
|
|
355
|
+
}
|
|
356
|
+
}, { once: true });
|
|
357
|
+
socket.addEventListener('close', (event) => {
|
|
358
|
+
clearTimeout(timeout);
|
|
359
|
+
reject(new Error(event.reason || 'language service closed'));
|
|
360
|
+
}, { once: true });
|
|
361
|
+
});
|
|
362
|
+
return { socket, workspaceUri };
|
|
363
|
+
}
|
|
364
|
+
function websocketAdapter(socket) {
|
|
365
|
+
return {
|
|
366
|
+
send: (content) => socket.send(content),
|
|
367
|
+
onMessage: (callback) => socket.addEventListener('message', (event) => callback(event.data)),
|
|
368
|
+
onError: (callback) => socket.addEventListener('error', (event) => callback(event)),
|
|
369
|
+
onClose: (callback) => socket.addEventListener('close', (event) => callback(event.code, event.reason)),
|
|
370
|
+
dispose: () => socket.close(),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function select(values) {
|
|
374
|
+
const element = document.createElement('select');
|
|
375
|
+
for (const value of values)
|
|
376
|
+
element.append(new Option(value, value));
|
|
377
|
+
return element;
|
|
378
|
+
}
|
|
379
|
+
function button(label) {
|
|
380
|
+
const element = document.createElement('button');
|
|
381
|
+
element.type = 'button';
|
|
382
|
+
element.textContent = label;
|
|
383
|
+
return element;
|
|
384
|
+
}
|
|
385
|
+
function renderStatus(status, meter, value) {
|
|
386
|
+
status.textContent = [
|
|
387
|
+
`[${value.target}] ${value.phase}`,
|
|
388
|
+
value.message,
|
|
389
|
+
value.compileLog,
|
|
390
|
+
]
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.join('\n');
|
|
393
|
+
if (value.usage) {
|
|
394
|
+
meter.textContent =
|
|
395
|
+
`units ${value.usage.hourUnitsUsed}/${value.usage.unitsPerHour ?? '∞'} · ` +
|
|
396
|
+
`compiles ${value.usage.compilesThisHour}/${value.usage.maxCompilesPerHour} · ` +
|
|
397
|
+
`gate ${value.usage.gateStatus}${value.usage.gateReason ? ` (${value.usage.gateReason})` : ''}`;
|
|
398
|
+
}
|
|
399
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crowdedkingdoms/crowdyjs",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.19.0",
|
|
4
4
|
"description": "Client SDK for Crowded Kingdoms GraphQL API with UDP proxy support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -70,11 +70,18 @@
|
|
|
70
70
|
"@graphql-tools/merge": "^9.1.10",
|
|
71
71
|
"@types/node": "^22.10.7",
|
|
72
72
|
"typescript": "^5.7.3",
|
|
73
|
-
"ws": "^8.
|
|
73
|
+
"ws": "^8.21.1"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
+
"@codingame/monaco-vscode-editor-api": "25.1.2",
|
|
76
77
|
"@graphql-typed-document-node/core": "^3.2.0",
|
|
78
|
+
"dompurify": "3.4.12",
|
|
77
79
|
"graphql": "^16.13.2",
|
|
78
|
-
"graphql-ws": "^6.0.8"
|
|
80
|
+
"graphql-ws": "^6.0.8",
|
|
81
|
+
"monaco-languageclient": "10.7.0",
|
|
82
|
+
"vscode-ws-jsonrpc": "3.5.0"
|
|
83
|
+
},
|
|
84
|
+
"overrides": {
|
|
85
|
+
"dompurify": "$dompurify"
|
|
79
86
|
}
|
|
80
87
|
}
|