silver-tongue 0.1.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.
@@ -0,0 +1,1871 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __commonJS = (cb, mod) => function __require() {
9
+ try {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ } catch (e) {
12
+ throw mod = 0, e;
13
+ }
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+
32
+ // ../../node_modules/@fluent/bundle/index.js
33
+ var require_bundle = __commonJS({
34
+ "../../node_modules/@fluent/bundle/index.js"(exports, module) {
35
+ (function(global, factory) {
36
+ typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define("@fluent/bundle", ["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.FluentBundle = {}));
37
+ })(exports, (function(exports2) {
38
+ "use strict";
39
+ class FluentType {
40
+ /**
41
+ * Create a `FluentType` instance.
42
+ *
43
+ * @param value The JavaScript value to wrap.
44
+ */
45
+ constructor(value) {
46
+ this.value = value;
47
+ }
48
+ /**
49
+ * Unwrap the raw value stored by this `FluentType`.
50
+ */
51
+ valueOf() {
52
+ return this.value;
53
+ }
54
+ }
55
+ class FluentNone extends FluentType {
56
+ /**
57
+ * Create an instance of `FluentNone` with an optional fallback value.
58
+ * @param value The fallback value of this `FluentNone`.
59
+ */
60
+ constructor(value = "???") {
61
+ super(value);
62
+ }
63
+ /**
64
+ * Format this `FluentNone` to the fallback string.
65
+ */
66
+ toString(scope) {
67
+ return `{${this.value}}`;
68
+ }
69
+ }
70
+ class FluentNumber extends FluentType {
71
+ /**
72
+ * Create an instance of `FluentNumber` with options to the
73
+ * `Intl.NumberFormat` constructor.
74
+ *
75
+ * @param value The number value of this `FluentNumber`.
76
+ * @param opts Options which will be passed to `Intl.NumberFormat`.
77
+ */
78
+ constructor(value, opts = {}) {
79
+ super(value);
80
+ this.opts = opts;
81
+ }
82
+ /**
83
+ * Format this `FluentNumber` to a string.
84
+ */
85
+ toString(scope) {
86
+ try {
87
+ const nf = scope.memoizeIntlObject(Intl.NumberFormat, this.opts);
88
+ return nf.format(this.value);
89
+ } catch (err) {
90
+ scope.reportError(err);
91
+ return this.value.toString(10);
92
+ }
93
+ }
94
+ }
95
+ class FluentDateTime extends FluentType {
96
+ /**
97
+ * Create an instance of `FluentDateTime` with options to the
98
+ * `Intl.DateTimeFormat` constructor.
99
+ *
100
+ * @param value The number value of this `FluentDateTime`, in milliseconds.
101
+ * @param opts Options which will be passed to `Intl.DateTimeFormat`.
102
+ */
103
+ constructor(value, opts = {}) {
104
+ super(value);
105
+ this.opts = opts;
106
+ }
107
+ /**
108
+ * Format this `FluentDateTime` to a string.
109
+ */
110
+ toString(scope) {
111
+ try {
112
+ const dtf = scope.memoizeIntlObject(Intl.DateTimeFormat, this.opts);
113
+ return dtf.format(this.value);
114
+ } catch (err) {
115
+ scope.reportError(err);
116
+ return new Date(this.value).toISOString();
117
+ }
118
+ }
119
+ }
120
+ const MAX_PLACEABLES = 100;
121
+ const FSI = "\u2068";
122
+ const PDI = "\u2069";
123
+ function match(scope, selector, key) {
124
+ if (key === selector) {
125
+ return true;
126
+ }
127
+ if (key instanceof FluentNumber && selector instanceof FluentNumber && key.value === selector.value) {
128
+ return true;
129
+ }
130
+ if (selector instanceof FluentNumber && typeof key === "string") {
131
+ let category = scope.memoizeIntlObject(Intl.PluralRules, selector.opts).select(selector.value);
132
+ if (key === category) {
133
+ return true;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+ function getDefault(scope, variants, star) {
139
+ if (variants[star]) {
140
+ return resolvePattern(scope, variants[star].value);
141
+ }
142
+ scope.reportError(new RangeError("No default"));
143
+ return new FluentNone();
144
+ }
145
+ function getArguments(scope, args) {
146
+ const positional = [];
147
+ const named = /* @__PURE__ */ Object.create(null);
148
+ for (const arg of args) {
149
+ if (arg.type === "narg") {
150
+ named[arg.name] = resolveExpression(scope, arg.value);
151
+ } else {
152
+ positional.push(resolveExpression(scope, arg));
153
+ }
154
+ }
155
+ return { positional, named };
156
+ }
157
+ function resolveExpression(scope, expr) {
158
+ switch (expr.type) {
159
+ case "str":
160
+ return expr.value;
161
+ case "num":
162
+ return new FluentNumber(expr.value, {
163
+ minimumFractionDigits: expr.precision
164
+ });
165
+ case "var":
166
+ return resolveVariableReference(scope, expr);
167
+ case "mesg":
168
+ return resolveMessageReference(scope, expr);
169
+ case "term":
170
+ return resolveTermReference(scope, expr);
171
+ case "func":
172
+ return resolveFunctionReference(scope, expr);
173
+ case "select":
174
+ return resolveSelectExpression(scope, expr);
175
+ default:
176
+ return new FluentNone();
177
+ }
178
+ }
179
+ function resolveVariableReference(scope, { name }) {
180
+ let arg;
181
+ if (scope.params) {
182
+ if (Object.prototype.hasOwnProperty.call(scope.params, name)) {
183
+ arg = scope.params[name];
184
+ } else {
185
+ return new FluentNone(`$${name}`);
186
+ }
187
+ } else if (scope.args && Object.prototype.hasOwnProperty.call(scope.args, name)) {
188
+ arg = scope.args[name];
189
+ } else {
190
+ scope.reportError(new ReferenceError(`Unknown variable: $${name}`));
191
+ return new FluentNone(`$${name}`);
192
+ }
193
+ if (arg instanceof FluentType) {
194
+ return arg;
195
+ }
196
+ switch (typeof arg) {
197
+ case "string":
198
+ return arg;
199
+ case "number":
200
+ return new FluentNumber(arg);
201
+ case "object":
202
+ if (arg instanceof Date) {
203
+ return new FluentDateTime(arg.getTime());
204
+ }
205
+ // eslint-disable-next-line no-fallthrough
206
+ default:
207
+ scope.reportError(new TypeError(`Variable type not supported: $${name}, ${typeof arg}`));
208
+ return new FluentNone(`$${name}`);
209
+ }
210
+ }
211
+ function resolveMessageReference(scope, { name, attr }) {
212
+ const message = scope.bundle._messages.get(name);
213
+ if (!message) {
214
+ scope.reportError(new ReferenceError(`Unknown message: ${name}`));
215
+ return new FluentNone(name);
216
+ }
217
+ if (attr) {
218
+ const attribute = message.attributes[attr];
219
+ if (attribute) {
220
+ return resolvePattern(scope, attribute);
221
+ }
222
+ scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`));
223
+ return new FluentNone(`${name}.${attr}`);
224
+ }
225
+ if (message.value) {
226
+ return resolvePattern(scope, message.value);
227
+ }
228
+ scope.reportError(new ReferenceError(`No value: ${name}`));
229
+ return new FluentNone(name);
230
+ }
231
+ function resolveTermReference(scope, { name, attr, args }) {
232
+ const id = `-${name}`;
233
+ const term2 = scope.bundle._terms.get(id);
234
+ if (!term2) {
235
+ scope.reportError(new ReferenceError(`Unknown term: ${id}`));
236
+ return new FluentNone(id);
237
+ }
238
+ if (attr) {
239
+ const attribute = term2.attributes[attr];
240
+ if (attribute) {
241
+ scope.params = getArguments(scope, args).named;
242
+ const resolved2 = resolvePattern(scope, attribute);
243
+ scope.params = null;
244
+ return resolved2;
245
+ }
246
+ scope.reportError(new ReferenceError(`Unknown attribute: ${attr}`));
247
+ return new FluentNone(`${id}.${attr}`);
248
+ }
249
+ scope.params = getArguments(scope, args).named;
250
+ const resolved = resolvePattern(scope, term2.value);
251
+ scope.params = null;
252
+ return resolved;
253
+ }
254
+ function resolveFunctionReference(scope, { name, args }) {
255
+ let func = scope.bundle._functions[name];
256
+ if (!func) {
257
+ scope.reportError(new ReferenceError(`Unknown function: ${name}()`));
258
+ return new FluentNone(`${name}()`);
259
+ }
260
+ if (typeof func !== "function") {
261
+ scope.reportError(new TypeError(`Function ${name}() is not callable`));
262
+ return new FluentNone(`${name}()`);
263
+ }
264
+ try {
265
+ let resolved = getArguments(scope, args);
266
+ return func(resolved.positional, resolved.named);
267
+ } catch (err) {
268
+ scope.reportError(err);
269
+ return new FluentNone(`${name}()`);
270
+ }
271
+ }
272
+ function resolveSelectExpression(scope, { selector, variants, star }) {
273
+ let sel = resolveExpression(scope, selector);
274
+ if (sel instanceof FluentNone) {
275
+ return getDefault(scope, variants, star);
276
+ }
277
+ for (const variant of variants) {
278
+ const key = resolveExpression(scope, variant.key);
279
+ if (match(scope, sel, key)) {
280
+ return resolvePattern(scope, variant.value);
281
+ }
282
+ }
283
+ return getDefault(scope, variants, star);
284
+ }
285
+ function resolveComplexPattern(scope, ptn) {
286
+ if (scope.dirty.has(ptn)) {
287
+ scope.reportError(new RangeError("Cyclic reference"));
288
+ return new FluentNone();
289
+ }
290
+ scope.dirty.add(ptn);
291
+ const result = [];
292
+ const useIsolating = scope.bundle._useIsolating && ptn.length > 1;
293
+ for (const elem of ptn) {
294
+ if (typeof elem === "string") {
295
+ result.push(scope.bundle._transform(elem));
296
+ continue;
297
+ }
298
+ scope.placeables++;
299
+ if (scope.placeables > MAX_PLACEABLES) {
300
+ scope.dirty.delete(ptn);
301
+ throw new RangeError(`Too many placeables expanded: ${scope.placeables}, max allowed is ${MAX_PLACEABLES}`);
302
+ }
303
+ if (useIsolating) {
304
+ result.push(FSI);
305
+ }
306
+ result.push(resolveExpression(scope, elem).toString(scope));
307
+ if (useIsolating) {
308
+ result.push(PDI);
309
+ }
310
+ }
311
+ scope.dirty.delete(ptn);
312
+ return result.join("");
313
+ }
314
+ function resolvePattern(scope, value) {
315
+ if (typeof value === "string") {
316
+ return scope.bundle._transform(value);
317
+ }
318
+ return resolveComplexPattern(scope, value);
319
+ }
320
+ class Scope {
321
+ constructor(bundle, errors, args) {
322
+ this.dirty = /* @__PURE__ */ new WeakSet();
323
+ this.params = null;
324
+ this.placeables = 0;
325
+ this.bundle = bundle;
326
+ this.errors = errors;
327
+ this.args = args;
328
+ }
329
+ reportError(error) {
330
+ if (!this.errors || !(error instanceof Error)) {
331
+ throw error;
332
+ }
333
+ this.errors.push(error);
334
+ }
335
+ memoizeIntlObject(ctor, opts) {
336
+ let cache2 = this.bundle._intls.get(ctor);
337
+ if (!cache2) {
338
+ cache2 = {};
339
+ this.bundle._intls.set(ctor, cache2);
340
+ }
341
+ let id = JSON.stringify(opts);
342
+ if (!cache2[id]) {
343
+ cache2[id] = new ctor(this.bundle.locales, opts);
344
+ }
345
+ return cache2[id];
346
+ }
347
+ }
348
+ function values(opts, allowed) {
349
+ const unwrapped = /* @__PURE__ */ Object.create(null);
350
+ for (const [name, opt] of Object.entries(opts)) {
351
+ if (allowed.includes(name)) {
352
+ unwrapped[name] = opt.valueOf();
353
+ }
354
+ }
355
+ return unwrapped;
356
+ }
357
+ const NUMBER_ALLOWED = [
358
+ "unitDisplay",
359
+ "currencyDisplay",
360
+ "useGrouping",
361
+ "minimumIntegerDigits",
362
+ "minimumFractionDigits",
363
+ "maximumFractionDigits",
364
+ "minimumSignificantDigits",
365
+ "maximumSignificantDigits"
366
+ ];
367
+ function NUMBER(args, opts) {
368
+ let arg = args[0];
369
+ if (arg instanceof FluentNone) {
370
+ return new FluentNone(`NUMBER(${arg.valueOf()})`);
371
+ }
372
+ if (arg instanceof FluentNumber) {
373
+ return new FluentNumber(arg.valueOf(), {
374
+ ...arg.opts,
375
+ ...values(opts, NUMBER_ALLOWED)
376
+ });
377
+ }
378
+ if (arg instanceof FluentDateTime) {
379
+ return new FluentNumber(arg.valueOf(), {
380
+ ...values(opts, NUMBER_ALLOWED)
381
+ });
382
+ }
383
+ throw new TypeError("Invalid argument to NUMBER");
384
+ }
385
+ const DATETIME_ALLOWED = [
386
+ "dateStyle",
387
+ "timeStyle",
388
+ "fractionalSecondDigits",
389
+ "dayPeriod",
390
+ "hour12",
391
+ "weekday",
392
+ "era",
393
+ "year",
394
+ "month",
395
+ "day",
396
+ "hour",
397
+ "minute",
398
+ "second",
399
+ "timeZoneName"
400
+ ];
401
+ function DATETIME(args, opts) {
402
+ let arg = args[0];
403
+ if (arg instanceof FluentNone) {
404
+ return new FluentNone(`DATETIME(${arg.valueOf()})`);
405
+ }
406
+ if (arg instanceof FluentDateTime) {
407
+ return new FluentDateTime(arg.valueOf(), {
408
+ ...arg.opts,
409
+ ...values(opts, DATETIME_ALLOWED)
410
+ });
411
+ }
412
+ if (arg instanceof FluentNumber) {
413
+ return new FluentDateTime(arg.valueOf(), {
414
+ ...values(opts, DATETIME_ALLOWED)
415
+ });
416
+ }
417
+ throw new TypeError("Invalid argument to DATETIME");
418
+ }
419
+ const cache = /* @__PURE__ */ new Map();
420
+ function getMemoizerForLocale(locales) {
421
+ const stringLocale = Array.isArray(locales) ? locales.join(" ") : locales;
422
+ let memoizer = cache.get(stringLocale);
423
+ if (memoizer === void 0) {
424
+ memoizer = /* @__PURE__ */ new Map();
425
+ cache.set(stringLocale, memoizer);
426
+ }
427
+ return memoizer;
428
+ }
429
+ class FluentBundle2 {
430
+ /**
431
+ * Create an instance of `FluentBundle`.
432
+ *
433
+ * @example
434
+ * ```js
435
+ * let bundle = new FluentBundle(["en-US", "en"]);
436
+ *
437
+ * let bundle = new FluentBundle(locales, {useIsolating: false});
438
+ *
439
+ * let bundle = new FluentBundle(locales, {
440
+ * useIsolating: true,
441
+ * functions: {
442
+ * NODE_ENV: () => process.env.NODE_ENV
443
+ * }
444
+ * });
445
+ * ```
446
+ *
447
+ * @param locales - Used to instantiate `Intl` formatters used by translations.
448
+ * @param options - Optional configuration for the bundle.
449
+ */
450
+ constructor(locales, { functions, useIsolating = true, transform = (v) => v } = {}) {
451
+ this._terms = /* @__PURE__ */ new Map();
452
+ this._messages = /* @__PURE__ */ new Map();
453
+ this.locales = Array.isArray(locales) ? locales : [locales];
454
+ this._functions = {
455
+ NUMBER,
456
+ DATETIME,
457
+ ...functions
458
+ };
459
+ this._useIsolating = useIsolating;
460
+ this._transform = transform;
461
+ this._intls = getMemoizerForLocale(locales);
462
+ }
463
+ /**
464
+ * Check if a message is present in the bundle.
465
+ *
466
+ * @param id - The identifier of the message to check.
467
+ */
468
+ hasMessage(id) {
469
+ return this._messages.has(id);
470
+ }
471
+ /**
472
+ * Return a raw unformatted message object from the bundle.
473
+ *
474
+ * Raw messages are `{value, attributes}` shapes containing translation units
475
+ * called `Patterns`. `Patterns` are implementation-specific; they should be
476
+ * treated as black boxes and formatted with `FluentBundle.formatPattern`.
477
+ *
478
+ * @param id - The identifier of the message to check.
479
+ */
480
+ getMessage(id) {
481
+ return this._messages.get(id);
482
+ }
483
+ /**
484
+ * Add a translation resource to the bundle.
485
+ *
486
+ * @example
487
+ * ```js
488
+ * let res = new FluentResource("foo = Foo");
489
+ * bundle.addResource(res);
490
+ * bundle.getMessage("foo");
491
+ * // → {value: .., attributes: {..}}
492
+ * ```
493
+ *
494
+ * @param res
495
+ * @param options
496
+ */
497
+ addResource(res, { allowOverrides = false } = {}) {
498
+ const errors = [];
499
+ for (let i = 0; i < res.body.length; i++) {
500
+ let entry = res.body[i];
501
+ if (entry.id.startsWith("-")) {
502
+ if (allowOverrides === false && this._terms.has(entry.id)) {
503
+ errors.push(new Error(`Attempt to override an existing term: "${entry.id}"`));
504
+ continue;
505
+ }
506
+ this._terms.set(entry.id, entry);
507
+ } else {
508
+ if (allowOverrides === false && this._messages.has(entry.id)) {
509
+ errors.push(new Error(`Attempt to override an existing message: "${entry.id}"`));
510
+ continue;
511
+ }
512
+ this._messages.set(entry.id, entry);
513
+ }
514
+ }
515
+ return errors;
516
+ }
517
+ /**
518
+ * Format a `Pattern` to a string.
519
+ *
520
+ * Format a raw `Pattern` into a string. `args` will be used to resolve
521
+ * references to variables passed as arguments to the translation.
522
+ *
523
+ * In case of errors `formatPattern` will try to salvage as much of the
524
+ * translation as possible and will still return a string. For performance
525
+ * reasons, the encountered errors are not returned but instead are appended
526
+ * to the `errors` array passed as the third argument.
527
+ *
528
+ * If `errors` is omitted, the first encountered error will be thrown.
529
+ *
530
+ * @example
531
+ * ```js
532
+ * let errors = [];
533
+ * bundle.addResource(
534
+ * new FluentResource("hello = Hello, {$name}!"));
535
+ *
536
+ * let hello = bundle.getMessage("hello");
537
+ * if (hello.value) {
538
+ * bundle.formatPattern(hello.value, {name: "Jane"}, errors);
539
+ * // Returns "Hello, Jane!" and `errors` is empty.
540
+ *
541
+ * bundle.formatPattern(hello.value, undefined, errors);
542
+ * // Returns "Hello, {$name}!" and `errors` is now:
543
+ * // [<ReferenceError: Unknown variable: name>]
544
+ * }
545
+ * ```
546
+ */
547
+ formatPattern(pattern, args = null, errors = null) {
548
+ if (typeof pattern === "string") {
549
+ return this._transform(pattern);
550
+ }
551
+ let scope = new Scope(this, errors, args);
552
+ try {
553
+ let value = resolveComplexPattern(scope, pattern);
554
+ return value.toString(scope);
555
+ } catch (err) {
556
+ if (scope.errors && err instanceof Error) {
557
+ scope.errors.push(err);
558
+ return new FluentNone().toString(scope);
559
+ }
560
+ throw err;
561
+ }
562
+ }
563
+ }
564
+ const RE_MESSAGE_START = /^(-?[a-zA-Z][\w-]*) *= */gm;
565
+ const RE_ATTRIBUTE_START = /\.([a-zA-Z][\w-]*) *= */y;
566
+ const RE_VARIANT_START = /\*?\[/y;
567
+ const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y;
568
+ const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y;
569
+ const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y;
570
+ const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/;
571
+ const RE_TEXT_RUN = /([^{}\n\r]+)/y;
572
+ const RE_STRING_RUN = /([^\\"\n\r]*)/y;
573
+ const RE_STRING_ESCAPE = /\\([\\"])/y;
574
+ const RE_UNICODE_ESCAPE = /\\u([a-fA-F0-9]{4})|\\U([a-fA-F0-9]{6})/y;
575
+ const RE_LEADING_NEWLINES = /^\n+/;
576
+ const RE_TRAILING_SPACES = / +$/;
577
+ const RE_BLANK_LINES = / *\r?\n/g;
578
+ const RE_INDENT = /( *)$/;
579
+ const TOKEN_BRACE_OPEN = /{\s*/y;
580
+ const TOKEN_BRACE_CLOSE = /\s*}/y;
581
+ const TOKEN_BRACKET_OPEN = /\[\s*/y;
582
+ const TOKEN_BRACKET_CLOSE = /\s*] */y;
583
+ const TOKEN_PAREN_OPEN = /\s*\(\s*/y;
584
+ const TOKEN_ARROW = /\s*->\s*/y;
585
+ const TOKEN_COLON = /\s*:\s*/y;
586
+ const TOKEN_COMMA = /\s*,?\s*/y;
587
+ const TOKEN_BLANK = /\s+/y;
588
+ class FluentResource2 {
589
+ constructor(source) {
590
+ this.body = [];
591
+ RE_MESSAGE_START.lastIndex = 0;
592
+ let cursor = 0;
593
+ while (true) {
594
+ let next = RE_MESSAGE_START.exec(source);
595
+ if (next === null) {
596
+ break;
597
+ }
598
+ cursor = RE_MESSAGE_START.lastIndex;
599
+ try {
600
+ this.body.push(parseMessage(next[1]));
601
+ } catch (err) {
602
+ if (err instanceof SyntaxError) {
603
+ continue;
604
+ }
605
+ throw err;
606
+ }
607
+ }
608
+ function test(re) {
609
+ re.lastIndex = cursor;
610
+ return re.test(source);
611
+ }
612
+ function consumeChar(char, errorClass) {
613
+ if (source[cursor] === char) {
614
+ cursor++;
615
+ return true;
616
+ }
617
+ if (errorClass) {
618
+ throw new errorClass(`Expected ${char}`);
619
+ }
620
+ return false;
621
+ }
622
+ function consumeToken(re, errorClass) {
623
+ if (test(re)) {
624
+ cursor = re.lastIndex;
625
+ return true;
626
+ }
627
+ if (errorClass) {
628
+ throw new errorClass(`Expected ${re.toString()}`);
629
+ }
630
+ return false;
631
+ }
632
+ function match2(re) {
633
+ re.lastIndex = cursor;
634
+ let result = re.exec(source);
635
+ if (result === null) {
636
+ throw new SyntaxError(`Expected ${re.toString()}`);
637
+ }
638
+ cursor = re.lastIndex;
639
+ return result;
640
+ }
641
+ function match1(re) {
642
+ return match2(re)[1];
643
+ }
644
+ function parseMessage(id) {
645
+ let value = parsePattern();
646
+ let attributes = parseAttributes();
647
+ if (value === null && Object.keys(attributes).length === 0) {
648
+ throw new SyntaxError("Expected message value or attributes");
649
+ }
650
+ return { id, value, attributes };
651
+ }
652
+ function parseAttributes() {
653
+ let attrs = /* @__PURE__ */ Object.create(null);
654
+ while (test(RE_ATTRIBUTE_START)) {
655
+ let name = match1(RE_ATTRIBUTE_START);
656
+ let value = parsePattern();
657
+ if (value === null) {
658
+ throw new SyntaxError("Expected attribute value");
659
+ }
660
+ attrs[name] = value;
661
+ }
662
+ return attrs;
663
+ }
664
+ function parsePattern() {
665
+ let first;
666
+ if (test(RE_TEXT_RUN)) {
667
+ first = match1(RE_TEXT_RUN);
668
+ }
669
+ if (source[cursor] === "{" || source[cursor] === "}") {
670
+ return parsePatternElements(first ? [first] : [], Infinity);
671
+ }
672
+ let indent = parseIndent();
673
+ if (indent) {
674
+ if (first) {
675
+ return parsePatternElements([first, indent], indent.length);
676
+ }
677
+ indent.value = trim(indent.value, RE_LEADING_NEWLINES);
678
+ return parsePatternElements([indent], indent.length);
679
+ }
680
+ if (first) {
681
+ return trim(first, RE_TRAILING_SPACES);
682
+ }
683
+ return null;
684
+ }
685
+ function parsePatternElements(elements = [], commonIndent) {
686
+ while (true) {
687
+ if (test(RE_TEXT_RUN)) {
688
+ elements.push(match1(RE_TEXT_RUN));
689
+ continue;
690
+ }
691
+ if (source[cursor] === "{") {
692
+ elements.push(parsePlaceable());
693
+ continue;
694
+ }
695
+ if (source[cursor] === "}") {
696
+ throw new SyntaxError("Unbalanced closing brace");
697
+ }
698
+ let indent = parseIndent();
699
+ if (indent) {
700
+ elements.push(indent);
701
+ commonIndent = Math.min(commonIndent, indent.length);
702
+ continue;
703
+ }
704
+ break;
705
+ }
706
+ let lastIndex = elements.length - 1;
707
+ let lastElement = elements[lastIndex];
708
+ if (typeof lastElement === "string") {
709
+ elements[lastIndex] = trim(lastElement, RE_TRAILING_SPACES);
710
+ }
711
+ let baked = [];
712
+ for (let element of elements) {
713
+ if (element instanceof Indent) {
714
+ element = element.value.slice(0, element.value.length - commonIndent);
715
+ }
716
+ if (element) {
717
+ baked.push(element);
718
+ }
719
+ }
720
+ return baked;
721
+ }
722
+ function parsePlaceable() {
723
+ consumeToken(TOKEN_BRACE_OPEN, SyntaxError);
724
+ let selector = parseInlineExpression();
725
+ if (consumeToken(TOKEN_BRACE_CLOSE)) {
726
+ return selector;
727
+ }
728
+ if (consumeToken(TOKEN_ARROW)) {
729
+ let variants = parseVariants();
730
+ consumeToken(TOKEN_BRACE_CLOSE, SyntaxError);
731
+ return {
732
+ type: "select",
733
+ selector,
734
+ ...variants
735
+ };
736
+ }
737
+ throw new SyntaxError("Unclosed placeable");
738
+ }
739
+ function parseInlineExpression() {
740
+ if (source[cursor] === "{") {
741
+ return parsePlaceable();
742
+ }
743
+ if (test(RE_REFERENCE)) {
744
+ let [, sigil, name, attr = null] = match2(RE_REFERENCE);
745
+ if (sigil === "$") {
746
+ return { type: "var", name };
747
+ }
748
+ if (consumeToken(TOKEN_PAREN_OPEN)) {
749
+ let args = parseArguments();
750
+ if (sigil === "-") {
751
+ return { type: "term", name, attr, args };
752
+ }
753
+ if (RE_FUNCTION_NAME.test(name)) {
754
+ return { type: "func", name, args };
755
+ }
756
+ throw new SyntaxError("Function names must be all upper-case");
757
+ }
758
+ if (sigil === "-") {
759
+ return {
760
+ type: "term",
761
+ name,
762
+ attr,
763
+ args: []
764
+ };
765
+ }
766
+ return { type: "mesg", name, attr };
767
+ }
768
+ return parseLiteral();
769
+ }
770
+ function parseArguments() {
771
+ let args = [];
772
+ while (true) {
773
+ switch (source[cursor]) {
774
+ case ")":
775
+ cursor++;
776
+ return args;
777
+ case void 0:
778
+ throw new SyntaxError("Unclosed argument list");
779
+ }
780
+ args.push(parseArgument());
781
+ consumeToken(TOKEN_COMMA);
782
+ }
783
+ }
784
+ function parseArgument() {
785
+ let expr = parseInlineExpression();
786
+ if (expr.type !== "mesg") {
787
+ return expr;
788
+ }
789
+ if (consumeToken(TOKEN_COLON)) {
790
+ return {
791
+ type: "narg",
792
+ name: expr.name,
793
+ value: parseLiteral()
794
+ };
795
+ }
796
+ return expr;
797
+ }
798
+ function parseVariants() {
799
+ let variants = [];
800
+ let count = 0;
801
+ let star;
802
+ while (test(RE_VARIANT_START)) {
803
+ if (consumeChar("*")) {
804
+ star = count;
805
+ }
806
+ let key = parseVariantKey();
807
+ let value = parsePattern();
808
+ if (value === null) {
809
+ throw new SyntaxError("Expected variant value");
810
+ }
811
+ variants[count++] = { key, value };
812
+ }
813
+ if (count === 0) {
814
+ return null;
815
+ }
816
+ if (star === void 0) {
817
+ throw new SyntaxError("Expected default variant");
818
+ }
819
+ return { variants, star };
820
+ }
821
+ function parseVariantKey() {
822
+ consumeToken(TOKEN_BRACKET_OPEN, SyntaxError);
823
+ let key;
824
+ if (test(RE_NUMBER_LITERAL)) {
825
+ key = parseNumberLiteral();
826
+ } else {
827
+ key = {
828
+ type: "str",
829
+ value: match1(RE_IDENTIFIER)
830
+ };
831
+ }
832
+ consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError);
833
+ return key;
834
+ }
835
+ function parseLiteral() {
836
+ if (test(RE_NUMBER_LITERAL)) {
837
+ return parseNumberLiteral();
838
+ }
839
+ if (source[cursor] === '"') {
840
+ return parseStringLiteral();
841
+ }
842
+ throw new SyntaxError("Invalid expression");
843
+ }
844
+ function parseNumberLiteral() {
845
+ let [, value, fraction = ""] = match2(RE_NUMBER_LITERAL);
846
+ let precision = fraction.length;
847
+ return {
848
+ type: "num",
849
+ value: parseFloat(value),
850
+ precision
851
+ };
852
+ }
853
+ function parseStringLiteral() {
854
+ consumeChar('"', SyntaxError);
855
+ let value = "";
856
+ while (true) {
857
+ value += match1(RE_STRING_RUN);
858
+ if (source[cursor] === "\\") {
859
+ value += parseEscapeSequence();
860
+ continue;
861
+ }
862
+ if (consumeChar('"')) {
863
+ return { type: "str", value };
864
+ }
865
+ throw new SyntaxError("Unclosed string literal");
866
+ }
867
+ }
868
+ function parseEscapeSequence() {
869
+ if (test(RE_STRING_ESCAPE)) {
870
+ return match1(RE_STRING_ESCAPE);
871
+ }
872
+ if (test(RE_UNICODE_ESCAPE)) {
873
+ let [, codepoint4, codepoint6] = match2(RE_UNICODE_ESCAPE);
874
+ let codepoint = parseInt(codepoint4 || codepoint6, 16);
875
+ return codepoint <= 55295 || 57344 <= codepoint ? (
876
+ // It's a Unicode scalar value.
877
+ String.fromCodePoint(codepoint)
878
+ ) : (
879
+ // Lonely surrogates can cause trouble when the parsing result is
880
+ // saved using UTF-8. Use U+FFFD REPLACEMENT CHARACTER instead.
881
+ "\uFFFD"
882
+ );
883
+ }
884
+ throw new SyntaxError("Unknown escape sequence");
885
+ }
886
+ function parseIndent() {
887
+ let start = cursor;
888
+ consumeToken(TOKEN_BLANK);
889
+ switch (source[cursor]) {
890
+ case ".":
891
+ case "[":
892
+ case "*":
893
+ case "}":
894
+ case void 0:
895
+ return false;
896
+ case "{":
897
+ return makeIndent(source.slice(start, cursor));
898
+ }
899
+ if (source[cursor - 1] === " ") {
900
+ return makeIndent(source.slice(start, cursor));
901
+ }
902
+ return false;
903
+ }
904
+ function trim(text, re) {
905
+ return text.replace(re, "");
906
+ }
907
+ function makeIndent(blank) {
908
+ let value = blank.replace(RE_BLANK_LINES, "\n");
909
+ let length = RE_INDENT.exec(blank)[1].length;
910
+ return new Indent(value, length);
911
+ }
912
+ }
913
+ }
914
+ class Indent {
915
+ constructor(value, length) {
916
+ this.value = value;
917
+ this.length = length;
918
+ }
919
+ }
920
+ exports2.FluentBundle = FluentBundle2;
921
+ exports2.FluentDateTime = FluentDateTime;
922
+ exports2.FluentNone = FluentNone;
923
+ exports2.FluentNumber = FluentNumber;
924
+ exports2.FluentResource = FluentResource2;
925
+ exports2.FluentType = FluentType;
926
+ }));
927
+ }
928
+ });
929
+
930
+ // src/main.ts
931
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
932
+ import { join as join2 } from "node:path";
933
+ import { fileURLToPath } from "node:url";
934
+
935
+ // ../core/src/types.ts
936
+ var REJECT_REASONS = [
937
+ "unknown-scene",
938
+ "in-scene",
939
+ "wrong-place",
940
+ "locked",
941
+ "no-slots",
942
+ "stale-run",
943
+ "no-pick",
944
+ "bad-choice",
945
+ "no-tiles",
946
+ "bad-tile",
947
+ "not-linked",
948
+ "unknown-word"
949
+ ];
950
+ var WALLET_REASONS = ["wages", "mixup", "food", "rent"];
951
+
952
+ // ../core/src/combo.ts
953
+ function comboKey(combo) {
954
+ return Object.keys(combo).sort().map((k) => `${k}=${combo[k]}`).join("|");
955
+ }
956
+ function parseComboKey(key) {
957
+ const out = {};
958
+ if (!key) return out;
959
+ for (const part of key.split("|")) {
960
+ const [k, v] = part.split("=");
961
+ out[k] = v;
962
+ }
963
+ return out;
964
+ }
965
+ function resolveParams(params, combo) {
966
+ const out = {};
967
+ for (const [k, v] of Object.entries(params)) out[k] = v.startsWith("$") ? combo[v.slice(1)] : v;
968
+ return out;
969
+ }
970
+
971
+ // ../core/src/learner.ts
972
+ var DAY_MS = 864e5;
973
+ var KNOWN_STREAK = 3;
974
+ var MAX_INTERVAL_DAYS = 60;
975
+ var RANK_THRESHOLDS = [0, 0.2, 0.4, 0.6, 0.85];
976
+ function decayIntervalMs(streak) {
977
+ return Math.min(MAX_INTERVAL_DAYS, 2 ** (streak - 2)) * DAY_MS;
978
+ }
979
+ function wordState(rec, now) {
980
+ if (!rec) return "unseen";
981
+ if (rec.lapsed) return "shaky";
982
+ if (rec.streak >= KNOWN_STREAK) return now - rec.lastSeen > decayIntervalMs(rec.streak) ? "shaky" : "known";
983
+ return "met";
984
+ }
985
+ function base(rec, now) {
986
+ return rec ? { ...rec } : { right: 0, wrong: 0, streak: 0, helps: 0, lapsed: false, firstSeen: now, lastSeen: now };
987
+ }
988
+ function recordSeen(rec, now) {
989
+ const r = base(rec, now);
990
+ if (wordState(rec, now) === "shaky") r.lapsed = true;
991
+ r.lastSeen = now;
992
+ return r;
993
+ }
994
+ function recordRight(rec, now) {
995
+ const r = recordSeen(rec, now);
996
+ r.right += 1;
997
+ r.streak += 1;
998
+ r.lapsed = false;
999
+ return r;
1000
+ }
1001
+ function recordWrong(rec, now) {
1002
+ const r = recordSeen(rec, now);
1003
+ r.wrong += 1;
1004
+ r.streak = 0;
1005
+ r.lapsed = true;
1006
+ return r;
1007
+ }
1008
+ function recordHelp(rec, now) {
1009
+ const r = recordSeen(rec, now);
1010
+ r.helps += 1;
1011
+ r.streak = 0;
1012
+ r.lapsed = true;
1013
+ return r;
1014
+ }
1015
+ var MODE_RANK = { unseen: 0, met: 0, shaky: 1, known: 2 };
1016
+ function replyModeFor(states, typing) {
1017
+ if (states.length === 0) return "pick";
1018
+ const r = Math.min(...states.map((s) => MODE_RANK[s]));
1019
+ if (r === 0) return "pick";
1020
+ if (r === 1) return "tiles";
1021
+ return typing ? "type" : "tiles";
1022
+ }
1023
+ function pickPreferred(candidates, wordsOf, records, now, rng) {
1024
+ const prio = (c) => Math.min(
1025
+ 2,
1026
+ ...wordsOf(c).map((w) => {
1027
+ const s = wordState(records[w], now);
1028
+ return s === "shaky" ? 0 : s === "met" ? 1 : 2;
1029
+ })
1030
+ );
1031
+ if (candidates.length === 0) throw new Error("pickPreferred: no candidates");
1032
+ const best = Math.min(...candidates.map(prio));
1033
+ const pool = candidates.filter((c) => prio(c) === best);
1034
+ return pool[Math.floor(rng() * pool.length)];
1035
+ }
1036
+ function rankFor(records, wordIds, now) {
1037
+ if (wordIds.length === 0) return 0;
1038
+ const known = wordIds.filter((w) => wordState(records[w], now) === "known").length;
1039
+ const share = known / wordIds.length;
1040
+ let rank = 0;
1041
+ RANK_THRESHOLDS.forEach((t, i) => {
1042
+ if (share >= t) rank = i;
1043
+ });
1044
+ return rank;
1045
+ }
1046
+
1047
+ // ../core/src/life.ts
1048
+ function isAvailable(scene, state2) {
1049
+ if (!scene.repeatable && (state2.scenesDone[scene.id] ?? 0) > 0) return false;
1050
+ if (!scene.after.every((id) => (state2.scenesDone[id] ?? 0) > 0)) return false;
1051
+ const trust = scene.requires.trust ?? {};
1052
+ return Object.entries(trust).every(([npc, min]) => (state2.trust[npc] ?? 0) >= min);
1053
+ }
1054
+ function availableSceneIds(course2, state2) {
1055
+ return course2.scenes.filter((s) => isAvailable(s, state2)).map((s) => s.id);
1056
+ }
1057
+ function changeWallet(state2, delta, reason) {
1058
+ const next = Math.max(0, state2.wallet + delta);
1059
+ const actual = next - state2.wallet;
1060
+ state2.wallet = next;
1061
+ return actual === 0 ? [] : [{ type: "walletChanged", wallet: next, delta: actual, reason }];
1062
+ }
1063
+ function addTrust(state2, npc, amount) {
1064
+ if (amount <= 0) return [];
1065
+ state2.trust[npc] = (state2.trust[npc] ?? 0) + amount;
1066
+ return [{ type: "trustChanged", npc, trust: state2.trust[npc] }];
1067
+ }
1068
+ function endDay(course2, state2) {
1069
+ const { foodPerDay, rentPerWeek } = course2.world;
1070
+ const events = [{ type: "dayEnded", day: state2.day }];
1071
+ events.push(...changeWallet(state2, -foodPerDay, "food"));
1072
+ if (state2.day % 7 === 0 || state2.rentLate) {
1073
+ if (state2.wallet >= rentPerWeek) {
1074
+ events.push(...changeWallet(state2, -rentPerWeek, "rent"));
1075
+ state2.rentLate = false;
1076
+ } else {
1077
+ state2.rentLate = true;
1078
+ }
1079
+ }
1080
+ state2.day += 1;
1081
+ state2.slot = 0;
1082
+ return events;
1083
+ }
1084
+ function newGame(course2) {
1085
+ return {
1086
+ v: 1,
1087
+ course: course2.id,
1088
+ day: 1,
1089
+ slot: 0,
1090
+ wallet: course2.world.startWallet,
1091
+ rentLate: false,
1092
+ place: course2.world.start,
1093
+ trust: {},
1094
+ words: {},
1095
+ scenesDone: {},
1096
+ run: null
1097
+ };
1098
+ }
1099
+
1100
+ // ../core/src/rng.ts
1101
+ function mulberry32(seed) {
1102
+ let a = seed >>> 0;
1103
+ return () => {
1104
+ a = a + 1831565813 >>> 0;
1105
+ let t = a;
1106
+ t = Math.imul(t ^ t >>> 15, t | 1);
1107
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1108
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1109
+ };
1110
+ }
1111
+ function shuffle(xs, rng) {
1112
+ const a = [...xs];
1113
+ for (let i = a.length - 1; i > 0; i--) {
1114
+ const j = Math.floor(rng() * (i + 1));
1115
+ [a[i], a[j]] = [a[j], a[i]];
1116
+ }
1117
+ return a;
1118
+ }
1119
+
1120
+ // ../core/src/dialogue.ts
1121
+ function reject(ctx, reason) {
1122
+ ctx.ev.push({ type: "inputRejected", reason });
1123
+ }
1124
+ function setWord(ctx, word, update) {
1125
+ const from = wordState(ctx.state.words[word], ctx.now);
1126
+ ctx.state.words[word] = update(ctx.state.words[word], ctx.now);
1127
+ const to = wordState(ctx.state.words[word], ctx.now);
1128
+ if (from !== to) ctx.ev.push({ type: "wordStateChanged", word, from, to });
1129
+ }
1130
+ function tilePieces(line) {
1131
+ return line.tokens.map((t) => line.text.slice(t.start, t.end));
1132
+ }
1133
+ function sceneById(ctx, id) {
1134
+ return ctx.course.scenes.find((s) => s.id === id);
1135
+ }
1136
+ function hingeWords(ctx, ex, combo) {
1137
+ const concepts = ex.hinges.map((h) => h.startsWith("$") ? combo[h.slice(1)] : h);
1138
+ return [...new Set(concepts.flatMap((c) => ctx.course.concepts[c] ?? []))];
1139
+ }
1140
+ function chooseCombo(ctx, ex) {
1141
+ const combo = {};
1142
+ for (const slot of Object.keys(ex.slots).sort()) {
1143
+ const values = ctx.course.groups[ex.slots[slot]];
1144
+ combo[slot] = pickPreferred(values, (c) => ctx.course.concepts[c] ?? [], ctx.state.words, ctx.now, ctx.rng);
1145
+ }
1146
+ return combo;
1147
+ }
1148
+ function speak(ctx, npc, line) {
1149
+ ctx.ev.push({ type: "lineSpoken", npc, line });
1150
+ for (const t of line.tokens) setWord(ctx, t.word, recordSeen);
1151
+ }
1152
+ function pickOptions(ctx, ex, combo) {
1153
+ const rightKey = comboKey(combo);
1154
+ const texts = /* @__PURE__ */ new Set([ex.variants[rightKey].reply.text]);
1155
+ const wrong = [];
1156
+ for (const key of shuffle(Object.keys(ex.variants), ctx.rng)) {
1157
+ if (wrong.length === 3) break;
1158
+ const c = parseComboKey(key);
1159
+ const differing = Object.keys(combo).filter((s) => c[s] !== combo[s]).length;
1160
+ const text = ex.variants[key].reply.text;
1161
+ if (differing === 1 && !texts.has(text)) {
1162
+ texts.add(text);
1163
+ wrong.push(key);
1164
+ }
1165
+ }
1166
+ return shuffle([rightKey, ...wrong], ctx.rng);
1167
+ }
1168
+ function buildTiles(ctx, ex, combo) {
1169
+ const rightKey = comboKey(combo);
1170
+ const pieces = tilePieces(ex.variants[rightKey].reply);
1171
+ const extra = /* @__PURE__ */ new Set();
1172
+ for (const key of shuffle(Object.keys(ex.variants), ctx.rng)) {
1173
+ if (key === rightKey) continue;
1174
+ for (const p of tilePieces(ex.variants[key].reply)) if (!pieces.includes(p)) extra.add(p);
1175
+ if (extra.size >= 2) break;
1176
+ }
1177
+ return shuffle([...pieces, ...[...extra].slice(0, 2)], ctx.rng);
1178
+ }
1179
+ function optionsEvent(ex, run) {
1180
+ return run.mode === "pick" ? { type: "replyOptions", mode: "pick", options: run.options.map((k) => ex.variants[k].reply) } : { type: "replyOptions", mode: "tiles", tiles: run.tiles };
1181
+ }
1182
+ function emitOptions(ctx, ex) {
1183
+ ctx.ev.push(optionsEvent(ex, ctx.state.run));
1184
+ }
1185
+ function describeRun(course2, state2) {
1186
+ const run = state2.run;
1187
+ const scene = run && course2.scenes.find((s) => s.id === run.scene);
1188
+ const ex = scene?.exchanges[run.exchange];
1189
+ const v = ex?.variants[comboKey(run.combo)];
1190
+ if (!run || !scene || !ex || !v) return [];
1191
+ return [
1192
+ { type: "sceneStarted", scene: scene.id, npc: scene.npc },
1193
+ { type: "lineSpoken", npc: scene.npc, line: v.npc },
1194
+ optionsEvent(ex, run)
1195
+ ];
1196
+ }
1197
+ function beginExchange(ctx, scene, index) {
1198
+ const run = ctx.state.run;
1199
+ const ex = scene.exchanges[index];
1200
+ const combo = chooseCombo(ctx, ex);
1201
+ speak(ctx, scene.npc, ex.variants[comboKey(combo)].npc);
1202
+ const states = hingeWords(ctx, ex, combo).map((w) => wordState(ctx.state.words[w], ctx.now));
1203
+ const mode = replyModeFor(states, ctx.course.typing);
1204
+ run.exchange = index;
1205
+ run.combo = combo;
1206
+ run.misses = 0;
1207
+ run.mode = mode === "type" ? "tiles" : mode;
1208
+ run.options = run.mode === "pick" ? pickOptions(ctx, ex, combo) : [];
1209
+ run.tiles = run.mode === "tiles" ? buildTiles(ctx, ex, combo) : [];
1210
+ emitOptions(ctx, ex);
1211
+ }
1212
+ function finishScene(ctx, scene) {
1213
+ const run = ctx.state.run;
1214
+ ctx.state.run = null;
1215
+ ctx.state.scenesDone[scene.id] = (ctx.state.scenesDone[scene.id] ?? 0) + 1;
1216
+ ctx.ev.push({ type: "sceneEnded", scene: scene.id, earned: run.earned });
1217
+ ctx.ev.push(...changeWallet(ctx.state, run.earned, "wages"));
1218
+ ctx.ev.push(...addTrust(ctx.state, scene.npc, scene.trustGain + (run.mixups === 0 ? 1 : 0)));
1219
+ }
1220
+ function resolve(ctx, scene, ex, chosen, diff, tilesWrong = false) {
1221
+ const run = ctx.state.run;
1222
+ const matched = diff.length === 0 && !tilesWrong;
1223
+ ctx.ev.push({ type: "actionPerformed", action: resolveParams(ex.expect, chosen), matched, diff, tilesWrong });
1224
+ const hinges = hingeWords(ctx, ex, run.combo);
1225
+ if (matched) {
1226
+ for (const w of hinges) setWord(ctx, w, recordRight);
1227
+ run.earned += ex.pay;
1228
+ if (run.exchange + 1 < scene.exchanges.length) beginExchange(ctx, scene, run.exchange + 1);
1229
+ else finishScene(ctx, scene);
1230
+ return;
1231
+ }
1232
+ for (const w of hinges) setWord(ctx, w, recordWrong);
1233
+ run.misses += 1;
1234
+ run.mixups += 1;
1235
+ ctx.ev.push(...changeWallet(ctx.state, -ex.missCost, "mixup"));
1236
+ const reaction = diff.map((d) => `wrong-${d}`).find((r) => ctx.course.reactions[r]) ?? "wrong-generic";
1237
+ ctx.ev.push({ type: "npcReacted", npc: scene.npc, reaction, line: ctx.course.reactions[reaction] });
1238
+ if (run.misses >= 2) {
1239
+ const v = ex.variants[comboKey(run.combo)];
1240
+ const line = v.rephrase ?? v.npc;
1241
+ ctx.ev.push({ type: "lineRephrased", npc: scene.npc, line, slow: !v.rephrase });
1242
+ for (const t of line.tokens) setWord(ctx, t.word, recordSeen);
1243
+ }
1244
+ emitOptions(ctx, ex);
1245
+ }
1246
+ function startScene(ctx, id) {
1247
+ const scene = sceneById(ctx, id);
1248
+ if (!scene) return reject(ctx, "unknown-scene");
1249
+ if (ctx.state.run) return reject(ctx, "in-scene");
1250
+ if (scene.place !== ctx.state.place) return reject(ctx, "wrong-place");
1251
+ if (!isAvailable(scene, ctx.state)) return reject(ctx, "locked");
1252
+ if (ctx.state.slot >= ctx.course.world.slotsPerDay) return reject(ctx, "no-slots");
1253
+ ctx.state.slot += 1;
1254
+ ctx.state.run = {
1255
+ scene: id,
1256
+ exchange: 0,
1257
+ combo: {},
1258
+ mode: "pick",
1259
+ options: [],
1260
+ tiles: [],
1261
+ misses: 0,
1262
+ earned: 0,
1263
+ mixups: 0
1264
+ };
1265
+ ctx.ev.push({ type: "sceneStarted", scene: id, npc: scene.npc });
1266
+ beginExchange(ctx, scene, 0);
1267
+ }
1268
+ function current(ctx) {
1269
+ const run = ctx.state.run;
1270
+ if (!run) return void 0;
1271
+ const scene = sceneById(ctx, run.scene);
1272
+ const ex = scene?.exchanges[run.exchange];
1273
+ if (!scene || !ex || !ex.variants[comboKey(run.combo)]) return void 0;
1274
+ return { scene, ex };
1275
+ }
1276
+ function actionDiff(ex, chosen, expected) {
1277
+ const got = resolveParams(ex.expect, chosen);
1278
+ const want = resolveParams(ex.expect, expected);
1279
+ return Object.keys(want).filter((k) => got[k] !== want[k]);
1280
+ }
1281
+ function reply(ctx, choice) {
1282
+ const run = ctx.state.run;
1283
+ if (run && !current(ctx)) return reject(ctx, "stale-run");
1284
+ const cur = current(ctx);
1285
+ if (!cur || !run || run.mode !== "pick") return reject(ctx, "no-pick");
1286
+ const key = run.options[choice];
1287
+ if (key === void 0) return reject(ctx, "bad-choice");
1288
+ const chosen = parseComboKey(key);
1289
+ resolve(ctx, cur.scene, cur.ex, chosen, actionDiff(cur.ex, chosen, run.combo));
1290
+ }
1291
+ function replyTiles(ctx, tiles) {
1292
+ const run = ctx.state.run;
1293
+ if (run && !current(ctx)) return reject(ctx, "stale-run");
1294
+ const cur = current(ctx);
1295
+ if (!cur || !run || run.mode !== "tiles") return reject(ctx, "no-tiles");
1296
+ if (tiles.some((i) => run.tiles[i] === void 0)) return reject(ctx, "bad-tile");
1297
+ const answer = tiles.map((i) => run.tiles[i]).join("");
1298
+ const target = tilePieces(cur.ex.variants[comboKey(run.combo)].reply).join("");
1299
+ resolve(ctx, cur.scene, cur.ex, run.combo, [], answer !== target);
1300
+ }
1301
+
1302
+ // ../core/src/save.ts
1303
+ var SAVE_VERSION = 1;
1304
+ function serialize(state2) {
1305
+ return JSON.stringify(state2);
1306
+ }
1307
+ var isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
1308
+ var isCount = (x) => Number.isInteger(x) && x >= 0;
1309
+ var isStrings = (x) => Array.isArray(x) && x.every((s) => typeof s === "string");
1310
+ var allValues = (o, ok) => Object.values(o).every(ok);
1311
+ function isWordRecord(x) {
1312
+ if (!isObj(x) || typeof x.lapsed !== "boolean") return false;
1313
+ return ["right", "wrong", "streak", "helps", "firstSeen", "lastSeen"].every((k) => isCount(x[k]));
1314
+ }
1315
+ function isRunShape(x) {
1316
+ if (!isObj(x) || typeof x.scene !== "string" || !isObj(x.combo) || !allValues(x.combo, (v) => typeof v === "string"))
1317
+ return false;
1318
+ if (!["pick", "tiles", "type"].includes(x.mode) || !isStrings(x.options) || !isStrings(x.tiles)) return false;
1319
+ return ["exchange", "misses", "earned", "mixups"].every((k) => isCount(x[k]));
1320
+ }
1321
+ function tilesCover(tiles, pieces) {
1322
+ const left = [...tiles];
1323
+ return pieces.every((p) => {
1324
+ const i = left.indexOf(p);
1325
+ return i >= 0 && left.splice(i, 1).length === 1;
1326
+ });
1327
+ }
1328
+ function runFits(run, course2) {
1329
+ const ex = course2.scenes.find((s) => s.id === run.scene)?.exchanges[run.exchange];
1330
+ const v = ex?.variants[comboKey(run.combo)];
1331
+ if (!ex || !v) return false;
1332
+ if (run.mode === "pick") return run.options.every((k) => !!ex.variants[k]);
1333
+ return tilesCover(run.tiles, tilePieces(v.reply));
1334
+ }
1335
+ function parseSave(raw, course2) {
1336
+ let data;
1337
+ try {
1338
+ data = JSON.parse(raw);
1339
+ } catch {
1340
+ return { ok: false, reason: "not-json" };
1341
+ }
1342
+ if (!isObj(data)) return { ok: false, reason: "not-object" };
1343
+ if (typeof data.v === "number" && data.v > SAVE_VERSION) return { ok: false, reason: "newer-version" };
1344
+ if (data.v !== SAVE_VERSION) return { ok: false, reason: "version" };
1345
+ if (data.course !== course2.id) return { ok: false, reason: "other-course" };
1346
+ if (!isCount(data.day)) return { ok: false, reason: "bad-day" };
1347
+ if (!isCount(data.slot) || data.slot > course2.world.slotsPerDay) return { ok: false, reason: "bad-slot" };
1348
+ if (!isCount(data.wallet)) return { ok: false, reason: "bad-wallet" };
1349
+ if (typeof data.rentLate !== "boolean") return { ok: false, reason: "bad-rentLate" };
1350
+ if (typeof data.place !== "string" || !course2.world.places[data.place]) return { ok: false, reason: "bad-place" };
1351
+ if (!isObj(data.trust) || !allValues(data.trust, isCount)) return { ok: false, reason: "bad-trust" };
1352
+ if (!isObj(data.scenesDone) || !allValues(data.scenesDone, isCount)) return { ok: false, reason: "bad-scenesDone" };
1353
+ if (!isObj(data.words) || !allValues(data.words, isWordRecord)) return { ok: false, reason: "bad-words" };
1354
+ if (data.run !== null && !isRunShape(data.run)) return { ok: false, reason: "bad-run" };
1355
+ const state2 = data;
1356
+ if (state2.run && !runFits(state2.run, course2)) state2.run = null;
1357
+ return { ok: true, state: state2 };
1358
+ }
1359
+
1360
+ // ../core/src/core.ts
1361
+ function handle(ctx, input) {
1362
+ const { course: course2, state: state2 } = ctx;
1363
+ switch (input.type) {
1364
+ case "goTo":
1365
+ if (state2.run) return reject(ctx, "in-scene");
1366
+ if (!course2.world.places[state2.place]?.links.includes(input.place)) return reject(ctx, "not-linked");
1367
+ state2.place = input.place;
1368
+ ctx.ev.push({ type: "placeEntered", place: input.place });
1369
+ return;
1370
+ case "startScene":
1371
+ return startScene(ctx, input.scene);
1372
+ case "reply":
1373
+ return reply(ctx, input.choice);
1374
+ case "replyTiles":
1375
+ return replyTiles(ctx, input.tiles);
1376
+ case "helpWord":
1377
+ if (!course2.words[input.word]) return reject(ctx, "unknown-word");
1378
+ return setWord(ctx, input.word, recordHelp);
1379
+ case "sleep":
1380
+ if (state2.run) return reject(ctx, "in-scene");
1381
+ ctx.ev.push(...endDay(course2, state2));
1382
+ return;
1383
+ }
1384
+ }
1385
+ function createCore(course2, initial, deps) {
1386
+ const wordIds = Object.keys(course2.words);
1387
+ let state2 = initial;
1388
+ return {
1389
+ get state() {
1390
+ return state2;
1391
+ },
1392
+ send(input) {
1393
+ const now = deps.now();
1394
+ const ctx = { course: course2, state: structuredClone(state2), now, rng: deps.rng, ev: [] };
1395
+ handle(ctx, input);
1396
+ if (ctx.ev.some((e) => e.type === "inputRejected")) return ctx.ev;
1397
+ const before = new Set(availableSceneIds(course2, state2));
1398
+ for (const id of availableSceneIds(course2, ctx.state)) {
1399
+ if (!before.has(id)) ctx.ev.push({ type: "unlocked", scene: id });
1400
+ }
1401
+ const rank = rankFor(ctx.state.words, wordIds, now);
1402
+ if (rank !== rankFor(state2.words, wordIds, now)) ctx.ev.push({ type: "rankChanged", rank });
1403
+ state2 = ctx.state;
1404
+ return ctx.ev;
1405
+ }
1406
+ };
1407
+ }
1408
+
1409
+ // ../tui/src/width.ts
1410
+ var isControl = (cp) => cp < 32 || cp >= 127 && cp < 160;
1411
+ function charWidth(cp) {
1412
+ if (isControl(cp)) return 0;
1413
+ if (/[\p{Mn}\p{Cf}]/u.test(String.fromCodePoint(cp))) return 0;
1414
+ if (cp >= 4352 && cp <= 4447 || cp >= 11904 && cp <= 42191 || cp >= 44032 && cp <= 55203 || cp >= 63744 && cp <= 64255 || cp >= 65072 && cp <= 65103 || cp >= 65280 && cp <= 65376 || cp >= 65504 && cp <= 65510 || cp === 126980 || cp >= 127744 && cp <= 129791 || cp >= 131072 && cp <= 262141) {
1415
+ return 2;
1416
+ }
1417
+ return 1;
1418
+ }
1419
+ function strWidth(s) {
1420
+ let w = 0;
1421
+ for (const ch of s) w += charWidth(ch.codePointAt(0));
1422
+ return w;
1423
+ }
1424
+ function fitLine(line, cols) {
1425
+ const out = [];
1426
+ let used = 0;
1427
+ for (const span of line) {
1428
+ let text = "";
1429
+ for (const raw of span.text) {
1430
+ const ch = isControl(raw.codePointAt(0)) ? " " : raw;
1431
+ const w = charWidth(ch.codePointAt(0));
1432
+ if (used + w > cols) break;
1433
+ text += ch;
1434
+ used += w;
1435
+ }
1436
+ if (text) out.push({ ...span, text });
1437
+ if (used >= cols) break;
1438
+ }
1439
+ if (used < cols) out.push({ text: " ".repeat(cols - used) });
1440
+ return out;
1441
+ }
1442
+
1443
+ // ../tui/src/text.ts
1444
+ var import_bundle = __toESM(require_bundle(), 1);
1445
+ function makeText(ftl, locale = "en") {
1446
+ const bundle = new import_bundle.FluentBundle(locale, { useIsolating: false });
1447
+ bundle.addResource(new import_bundle.FluentResource(ftl));
1448
+ return (id, args) => {
1449
+ const msg = bundle.getMessage(id);
1450
+ return msg?.value ? bundle.formatPattern(msg.value, args ?? {}, []) : id;
1451
+ };
1452
+ }
1453
+ var UI_KEYS = {
1454
+ hud: ["day", "slot", "slots", "currency", "wallet", "rank"],
1455
+ "rank-0": [],
1456
+ "rank-1": [],
1457
+ "rank-2": [],
1458
+ "rank-3": [],
1459
+ "rank-4": [],
1460
+ "menu-title": [],
1461
+ "menu-talk": ["npc", "scene"],
1462
+ "menu-go": ["place"],
1463
+ "menu-sleep": [],
1464
+ "menu-quit": [],
1465
+ "keys-explore": [],
1466
+ "keys-pick": [],
1467
+ "keys-tiles": [],
1468
+ "keys-help": [],
1469
+ "help-title": [],
1470
+ "tiles-answer": [],
1471
+ mismatch: [],
1472
+ rephrased: [],
1473
+ "wallet-change": ["sign", "currency", "amount", "reason"],
1474
+ ...Object.fromEntries(WALLET_REASONS.map((r) => [`reason-${r}`, []])),
1475
+ "trust-up": ["npc", "trust"],
1476
+ "scene-done": ["currency", "earned"],
1477
+ unlocked: ["scene"],
1478
+ "rank-up": ["rank"],
1479
+ "day-ended": ["day"],
1480
+ "notice-bad-save": [],
1481
+ "notice-read-only": [],
1482
+ ...Object.fromEntries(REJECT_REASONS.map((c) => [`reject-${c}`, []]))
1483
+ };
1484
+
1485
+ // ../tui/src/screen.ts
1486
+ function lineSpans(line, fresh) {
1487
+ const out = [];
1488
+ let at = 0;
1489
+ for (const t of line.tokens) {
1490
+ if (t.start > at) out.push({ text: line.text.slice(at, t.start) });
1491
+ out.push({ text: line.text.slice(t.start, t.end), underline: fresh.has(t.word) });
1492
+ at = t.end;
1493
+ }
1494
+ if (at < line.text.length) out.push({ text: line.text.slice(at) });
1495
+ return out;
1496
+ }
1497
+ function wrapItems(items, width, gap = " ") {
1498
+ const lines = [];
1499
+ for (const item of items) {
1500
+ const last = lines.at(-1);
1501
+ if (last !== void 0 && strWidth(last) + strWidth(gap) + strWidth(item) <= width) lines[lines.length - 1] = last + gap + item;
1502
+ else lines.push(item);
1503
+ }
1504
+ return lines.map((text) => [{ text }]);
1505
+ }
1506
+ function border(left, label, right, fill, cols, rightLabel = "") {
1507
+ const inner = cols - 2;
1508
+ const l = label ? ` ${label} ` : "";
1509
+ const r = rightLabel ? ` ${rightLabel} ` : "";
1510
+ const gap = Math.max(0, inner - strWidth(l) - strWidth(r));
1511
+ return fitLine(
1512
+ [
1513
+ { text: left, dim: true },
1514
+ { text: l, bold: true },
1515
+ { text: fill.repeat(gap), dim: true },
1516
+ { text: r },
1517
+ { text: right, dim: true }
1518
+ ],
1519
+ cols
1520
+ );
1521
+ }
1522
+ function renderScreen(m, cols, rows) {
1523
+ const inner = Math.max(1, cols - 4);
1524
+ const bodyRows = Math.max(1, rows - 2);
1525
+ const prompt = m.prompt.slice(-bodyRows);
1526
+ const logRows = Math.max(0, bodyRows - prompt.length - (prompt.length ? 1 : 0));
1527
+ const log = logRows > 0 ? m.log.slice(-logRows) : [];
1528
+ const body = [...Array(logRows - log.length).fill([]), ...log];
1529
+ if (prompt.length) body.push([]);
1530
+ body.push(...prompt);
1531
+ return [
1532
+ border("\u250C", m.title, "\u2510", "\u2500", cols, m.hud),
1533
+ ...body.map((l) => [{ text: "\u2502 ", dim: true }, ...fitLine(l, inner), { text: " \u2502", dim: true }]),
1534
+ border("\u2514", m.footer, "\u2518", "\u2500", cols)
1535
+ ];
1536
+ }
1537
+
1538
+ // ../tui/src/app.ts
1539
+ var LOG_LIMIT = 200;
1540
+ function startApp(opts) {
1541
+ const { course: course2, core: core2, term: term2 } = opts;
1542
+ const t = makeText(course2.learnerFtl);
1543
+ const wordIds = Object.keys(course2.words);
1544
+ let mode = "explore";
1545
+ let log = [];
1546
+ let pickOptions2 = [];
1547
+ let tiles = [];
1548
+ let replyMode = "pick";
1549
+ let tileInput = [];
1550
+ let lastLine = null;
1551
+ const push = (...lines) => {
1552
+ log = [...log, ...lines].slice(-LOG_LIMIT);
1553
+ };
1554
+ const npcName = (npc) => t(`npc-${npc}`);
1555
+ const say = (npc, line, fresh, suffix = "") => [
1556
+ { text: `${npcName(npc)}${suffix}: `, color: "cyan", bold: true },
1557
+ ...lineSpans(line, fresh)
1558
+ ];
1559
+ function enterPlace(place) {
1560
+ push([], [{ text: t(`place-${place}`), bold: true }], [{ text: t(`place-${place}-desc`), dim: true }]);
1561
+ }
1562
+ function apply(events) {
1563
+ const fresh = new Set(
1564
+ events.flatMap((e) => e.type === "wordStateChanged" && e.from === "unseen" ? [e.word] : [])
1565
+ );
1566
+ for (const e of events) {
1567
+ switch (e.type) {
1568
+ case "placeEntered":
1569
+ enterPlace(e.place);
1570
+ break;
1571
+ case "sceneStarted":
1572
+ mode = "scene";
1573
+ push([]);
1574
+ break;
1575
+ case "lineSpoken":
1576
+ lastLine = e.line;
1577
+ push(say(e.npc, e.line, fresh));
1578
+ break;
1579
+ case "replyOptions":
1580
+ replyMode = e.mode;
1581
+ tileInput = [];
1582
+ if (e.mode === "pick") pickOptions2 = e.options;
1583
+ else tiles = e.tiles;
1584
+ break;
1585
+ case "actionPerformed":
1586
+ if (!e.matched) push([{ text: t("mismatch"), color: "yellow" }]);
1587
+ break;
1588
+ case "npcReacted":
1589
+ push(say(e.npc, e.line, fresh));
1590
+ break;
1591
+ case "lineRephrased":
1592
+ lastLine = e.line;
1593
+ push(say(e.npc, e.line, fresh, ` ${t("rephrased")}`));
1594
+ break;
1595
+ case "walletChanged":
1596
+ push([
1597
+ {
1598
+ text: t("wallet-change", {
1599
+ sign: e.delta > 0 ? "+" : "-",
1600
+ amount: Math.abs(e.delta),
1601
+ currency: course2.world.currency,
1602
+ reason: t(`reason-${e.reason}`)
1603
+ }),
1604
+ color: e.delta > 0 ? "green" : "red"
1605
+ }
1606
+ ]);
1607
+ break;
1608
+ case "trustChanged":
1609
+ push([{ text: t("trust-up", { npc: npcName(e.npc), trust: e.trust }), color: "magenta" }]);
1610
+ break;
1611
+ case "sceneEnded":
1612
+ mode = "explore";
1613
+ push([{ text: t("scene-done", { currency: course2.world.currency, earned: e.earned }), bold: true }]);
1614
+ break;
1615
+ case "unlocked":
1616
+ push([{ text: t("unlocked", { scene: t(`scene-${e.scene}`) }), color: "green" }]);
1617
+ break;
1618
+ case "rankChanged":
1619
+ push([{ text: t("rank-up", { rank: t(`rank-${e.rank}`) }), color: "yellow", bold: true }]);
1620
+ break;
1621
+ case "dayEnded":
1622
+ push([], [{ text: t("day-ended", { day: e.day }), dim: true }]);
1623
+ break;
1624
+ case "inputRejected":
1625
+ push([{ text: t(`reject-${e.reason}`), color: "red" }]);
1626
+ break;
1627
+ case "wordStateChanged":
1628
+ break;
1629
+ }
1630
+ }
1631
+ }
1632
+ let save = opts.save;
1633
+ function persist() {
1634
+ if (save && !save(core2.state)) {
1635
+ save = void 0;
1636
+ push([{ text: t("notice-read-only"), color: "yellow" }]);
1637
+ }
1638
+ }
1639
+ function send(input) {
1640
+ const events = core2.send(input);
1641
+ apply(events);
1642
+ if (!events.some((e) => e.type === "inputRejected")) persist();
1643
+ }
1644
+ function menu() {
1645
+ const s = core2.state;
1646
+ const items = [];
1647
+ for (const id of availableSceneIds(course2, s)) {
1648
+ const scene = course2.scenes.find((x) => x.id === id);
1649
+ if (scene.place !== s.place) continue;
1650
+ items.push({ label: t("menu-talk", { npc: npcName(scene.npc), scene: t(`scene-${id}`) }), input: { type: "startScene", scene: id } });
1651
+ }
1652
+ for (const p of course2.world.places[s.place].links) {
1653
+ items.push({ label: t("menu-go", { place: t(`place-${p}`) }), input: { type: "goTo", place: p } });
1654
+ }
1655
+ return [...items.slice(0, 7), { label: t("menu-sleep"), input: { type: "sleep" } }, { label: t("menu-quit"), quit: true }];
1656
+ }
1657
+ function helpWords() {
1658
+ if (!lastLine) return [];
1659
+ const line = lastLine;
1660
+ return line.tokens.map((tk) => ({ text: line.text.slice(tk.start, tk.end), word: tk.word }));
1661
+ }
1662
+ function prompt(width) {
1663
+ if (mode === "explore") {
1664
+ return [[{ text: t("menu-title"), dim: true }], ...menu().map((m, i) => [{ text: `${i + 1}) ${m.label}` }])];
1665
+ }
1666
+ if (mode === "help") {
1667
+ const words = helpWords().map((w, i) => `${i + 1}) ${w.text}`);
1668
+ return [[{ text: t("help-title"), dim: true }], ...wrapItems(words, width)];
1669
+ }
1670
+ if (replyMode === "pick") return pickOptions2.map((o, i) => [{ text: `${i + 1}) ` }, ...lineSpans(o, /* @__PURE__ */ new Set())]);
1671
+ return [
1672
+ ...wrapItems(
1673
+ tiles.map((x, i) => `[${i + 1}]${x}`),
1674
+ width,
1675
+ " "
1676
+ ),
1677
+ [{ text: `${t("tiles-answer")} `, dim: true }, { text: tileInput.map((i) => tiles[i]).join(""), bold: true }]
1678
+ ];
1679
+ }
1680
+ function render() {
1681
+ const s = core2.state;
1682
+ const { cols, rows } = term2.size();
1683
+ const hud = t("hud", {
1684
+ day: s.day,
1685
+ slot: s.slot,
1686
+ slots: course2.world.slotsPerDay,
1687
+ currency: course2.world.currency,
1688
+ wallet: s.wallet,
1689
+ rank: t(`rank-${rankFor(s.words, wordIds, opts.now())}`)
1690
+ });
1691
+ const footer = t(mode === "explore" ? "keys-explore" : mode === "help" ? "keys-help" : replyMode === "pick" ? "keys-pick" : "keys-tiles");
1692
+ term2.write(renderScreen({ title: t(`place-${s.place}`), hud, log, prompt: prompt(cols - 4), footer }, cols, rows));
1693
+ }
1694
+ function press(key) {
1695
+ if (key.name === "ctrl-c") return opts.quit();
1696
+ const n = /^[1-9]$/.test(key.name) ? Number(key.name) - 1 : -1;
1697
+ if (mode === "explore") {
1698
+ const item = n >= 0 ? menu()[n] : void 0;
1699
+ if (key.name === "q" || item?.quit) return opts.quit();
1700
+ if (item?.input) send(item.input);
1701
+ } else if (mode === "help") {
1702
+ const word = n >= 0 ? helpWords()[n] : void 0;
1703
+ if (word) {
1704
+ send({ type: "helpWord", word: word.word });
1705
+ const w = course2.words[word.word];
1706
+ push([
1707
+ { text: w.w, bold: true },
1708
+ ...w.pron ? [{ text: ` ${w.pron}`, color: "yellow" }] : [],
1709
+ { text: ` \u2014 ${w.gloss}` }
1710
+ ]);
1711
+ }
1712
+ if (key.name === "escape" || key.name === "w") mode = "scene";
1713
+ } else if (key.name === "w") {
1714
+ mode = "help";
1715
+ } else if (replyMode === "pick") {
1716
+ if (n >= 0 && n < pickOptions2.length) send({ type: "reply", choice: n });
1717
+ } else if (n >= 0 && n < tiles.length && !tileInput.includes(n)) {
1718
+ tileInput = [...tileInput, n];
1719
+ } else if (key.name === "backspace") {
1720
+ tileInput = tileInput.slice(0, -1);
1721
+ } else if (key.name === "return" && tileInput.length) {
1722
+ send({ type: "replyTiles", tiles: tileInput });
1723
+ }
1724
+ render();
1725
+ }
1726
+ if (opts.notice) push([{ text: t(opts.notice), color: "yellow" }]);
1727
+ enterPlace(core2.state.place);
1728
+ apply(describeRun(course2, core2.state));
1729
+ term2.onKey(press);
1730
+ term2.onResize(render);
1731
+ render();
1732
+ return { press, render };
1733
+ }
1734
+
1735
+ // src/node-terminal.ts
1736
+ import readline from "node:readline";
1737
+ var COLORS = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36 };
1738
+ function toAnsi(line) {
1739
+ return line.map((s) => {
1740
+ const codes = [
1741
+ ...s.bold ? [1] : [],
1742
+ ...s.dim ? [2] : [],
1743
+ ...s.underline ? [4] : [],
1744
+ ...s.color ? [COLORS[s.color]] : []
1745
+ ];
1746
+ return codes.length ? `\x1B[${codes.join(";")}m${s.text}\x1B[0m` : s.text;
1747
+ }).join("");
1748
+ }
1749
+ function keyName(str, key) {
1750
+ if (key?.ctrl && key.name === "c") return "ctrl-c";
1751
+ return key?.name ?? str;
1752
+ }
1753
+ function createNodeTerminal(input = process.stdin, output = process.stdout) {
1754
+ readline.emitKeypressEvents(input);
1755
+ if (input.isTTY) input.setRawMode(true);
1756
+ output.write("\x1B[?1049h\x1B[?25l");
1757
+ const handlers = [];
1758
+ input.on("keypress", (str, key) => {
1759
+ const name = keyName(str, key);
1760
+ if (name) for (const h of handlers) h({ name });
1761
+ });
1762
+ input.on("end", () => {
1763
+ for (const h of handlers) h({ name: "ctrl-c" });
1764
+ });
1765
+ let closed = false;
1766
+ const restore = () => {
1767
+ if (closed) return;
1768
+ closed = true;
1769
+ if (input.isTTY) input.setRawMode(false);
1770
+ input.pause();
1771
+ output.write("\x1B[0m\x1B[?25h\x1B[?1049l");
1772
+ };
1773
+ process.on("exit", restore);
1774
+ return {
1775
+ write(lines) {
1776
+ output.write("\x1B[H\x1B[2J" + lines.map(toAnsi).join("\r\n"));
1777
+ },
1778
+ onKey(handler) {
1779
+ handlers.push(handler);
1780
+ },
1781
+ onResize(handler) {
1782
+ output.on("resize", handler);
1783
+ },
1784
+ size() {
1785
+ return { cols: output.columns || 80, rows: output.rows || 24 };
1786
+ },
1787
+ /** Restores the terminal. Safe to call more than once. */
1788
+ close: restore
1789
+ };
1790
+ }
1791
+
1792
+ // src/storage.ts
1793
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
1794
+ import { homedir } from "node:os";
1795
+ import { dirname, join } from "node:path";
1796
+ function configDir(env = process.env, platform = process.platform, home = homedir()) {
1797
+ if (platform === "win32") return env.APPDATA || join(home, "AppData", "Roaming");
1798
+ return env.XDG_CONFIG_HOME || join(home, ".config");
1799
+ }
1800
+ function loadSave(course2, path) {
1801
+ try {
1802
+ if (!existsSync(path)) return { state: newGame(course2), readOnly: false };
1803
+ const parsed = parseSave(readFileSync(path, "utf8"), course2);
1804
+ if (parsed.ok) return { state: parsed.state, readOnly: false };
1805
+ renameSync(path, `${path}.invalid-backup`);
1806
+ return { state: newGame(course2), notice: "notice-bad-save", readOnly: false };
1807
+ } catch {
1808
+ return { state: newGame(course2), notice: "notice-read-only", readOnly: true };
1809
+ }
1810
+ }
1811
+ function writeSave(path, state2) {
1812
+ try {
1813
+ mkdirSync(dirname(path), { recursive: true });
1814
+ const tmp = `${path}.tmp`;
1815
+ const fd = openSync(tmp, "w");
1816
+ try {
1817
+ writeSync(fd, serialize(state2));
1818
+ fsyncSync(fd);
1819
+ } finally {
1820
+ closeSync(fd);
1821
+ }
1822
+ renameSync(tmp, path);
1823
+ return true;
1824
+ } catch {
1825
+ return false;
1826
+ }
1827
+ }
1828
+
1829
+ // src/main.ts
1830
+ var COURSE = "zh-china-en";
1831
+ function coursePath() {
1832
+ if (process.argv[2]) return process.argv[2];
1833
+ const candidates = [
1834
+ new URL(`./courses/${COURSE}/course.json`, import.meta.url),
1835
+ new URL(`../../../dist/courses/${COURSE}/course.json`, import.meta.url)
1836
+ ].map((u) => fileURLToPath(u));
1837
+ const found = candidates.find((p) => existsSync2(p));
1838
+ if (!found) {
1839
+ console.error(`No built course found. Run: npm run build:course`);
1840
+ process.exit(1);
1841
+ }
1842
+ return found;
1843
+ }
1844
+ var [major] = process.versions.node.split(".").map(Number);
1845
+ if (major < 22) {
1846
+ console.error(`silver-tongue needs Node 22 or newer; this is Node ${process.versions.node}.`);
1847
+ process.exit(1);
1848
+ }
1849
+ var course = JSON.parse(readFileSync2(coursePath(), "utf8"));
1850
+ var savePath = process.env.SILVER_TONGUE_SAVE ?? join2(configDir(), "silver-tongue", `${course.id}.json`);
1851
+ var { state, notice, readOnly } = loadSave(course, savePath);
1852
+ var core = createCore(course, state, { now: Date.now, rng: mulberry32(Date.now() >>> 0) });
1853
+ var term = createNodeTerminal();
1854
+ var bail = (code, error) => {
1855
+ term.close();
1856
+ if (error) console.error(error);
1857
+ process.exit(code);
1858
+ };
1859
+ process.on("uncaughtException", (e) => bail(1, e));
1860
+ process.on("unhandledRejection", (e) => bail(1, e));
1861
+ process.on("SIGTERM", () => bail(143));
1862
+ process.on("SIGHUP", () => bail(129));
1863
+ startApp({
1864
+ course,
1865
+ core,
1866
+ term,
1867
+ now: Date.now,
1868
+ notice,
1869
+ save: readOnly ? void 0 : (s) => writeSave(savePath, s),
1870
+ quit: () => bail(0)
1871
+ });