silver-tongue 0.3.1 → 0.4.1
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 +9 -1
- package/dist/courses/zh-china-en/course.json +1 -1
- package/dist/silver-tongue.mjs +323 -54
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
> You arrive speaking pidgin; you leave with a silver tongue.
|
|
4
4
|
|
|
5
|
-
A language-learning life game. Your phone is dead, your wallet is gone, and you're standing on a street in a Chinese city with ¥20 in your pocket. An old man on a bench teaches you your first words; after that you earn your living by understanding people. Every job, purchase and conversation happens in the language you're learning.
|
|
5
|
+
A language-learning life game. Your phone is dead, your wallet is gone, and you're standing on a street in a Chinese city with ¥20 in your pocket. An old man on a bench teaches you your first words, and later explains how the language works when you ask; after that you earn your living by understanding people. Every job, purchase and conversation happens in the language you're learning.
|
|
6
6
|
|
|
7
7
|
The first course is Mandarin (HSK 1), in a Chinese city, explained in English.
|
|
8
8
|
|
|
9
|
+
## Play in the browser
|
|
10
|
+
|
|
11
|
+
https://jamil314.github.io/silver-tongue/ — the same game, with an on-screen key bar on phones. Games save in the browser; Export and Import move a game between the browser and the terminal.
|
|
12
|
+
|
|
9
13
|
## Play in the terminal
|
|
10
14
|
|
|
11
15
|
```sh
|
|
12
16
|
npx silver-tongue # continue the game you played last
|
|
13
17
|
npx silver-tongue --new # start a new game (your other games are kept)
|
|
14
18
|
npx silver-tongue --resume # choose one of your saved games
|
|
19
|
+
npx silver-tongue --export # print the game you played last as one line of text
|
|
20
|
+
npx silver-tongue --import <line> # add a game exported elsewhere (nothing is overwritten)
|
|
21
|
+
npx silver-tongue --import - < game.txt # the same, reading the line from a file or a paste
|
|
15
22
|
```
|
|
16
23
|
|
|
17
24
|
| Key | Does |
|
|
@@ -19,6 +26,7 @@ npx silver-tongue --resume # choose one of your saved games
|
|
|
19
26
|
| `1`–`9` | choose from the menu, or pick a reply |
|
|
20
27
|
| `w` | word help: look up a word from the last line or the replies; `s` explains the whole sentence |
|
|
21
28
|
| `enter` / `⌫` | say / undo, when building a reply from tiles |
|
|
29
|
+
| `n` | notebook: every word you've heard, where you first heard it, and Old Wang's notes |
|
|
22
30
|
| `esc` | back |
|
|
23
31
|
| `q` | save and quit (from the menu) |
|
|
24
32
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"zh-china-en","typing":false,"words":{"w0003":{"id":"w0003","w":"三","lv":"1","gloss":"three; 3","pron":"sān"},"w0009":{"id":"w0009","w":"不","lv":"1","gloss":"no; not","pron":"bù"},"w0010":{"id":"w0010","w":"不客气","lv":"1","gloss":"you're welcome; don't mention it","pron":"bùkèqi"},"w0020":{"id":"w0020","w":"五","lv":"1","gloss":"five; 5","pron":"wǔ"},"w0028":{"id":"w0028","w":"你","lv":"1","gloss":"you (informal)","pron":"nǐ"},"w0034":{"id":"w0034","w":"再见","lv":"1","gloss":"goodbye","pron":"zàijiàn"},"w0037":{"id":"w0037","w":"几","lv":"1","gloss":"how much; how many","pron":"jǐ"},"w0051":{"id":"w0051","w":"吗","lv":"1","gloss":"(question particle for \"yes-no\" questions)","pron":"ma"},"w0060":{"id":"w0060","w":"四","lv":"1","gloss":"four; 4","pron":"sì"},"w0072":{"id":"w0072","w":"好","lv":"1","gloss":"good; appropriate; proper","pron":"hǎo"},"w0079":{"id":"w0079","w":"对不起","lv":"1","gloss":"I'm sorry; excuse me; I beg your pardon","pron":"duìbuqǐ"},"w0084":{"id":"w0084","w":"工作","lv":"1","gloss":"to work; (of a machine) to operate","pron":"gōngzuò"},"w0099":{"id":"w0099","w":"是","lv":"1","gloss":"to be (followed by substantives only); correct; right; true","pron":"shì"},"w0108":{"id":"w0108","w":"水","lv":"1","gloss":"water; river","pron":"shuǐ"},"w0112":{"id":"w0112","w":"没关系","lv":"1","gloss":"it doesn't matter","pron":"méiguānxi"},"w0133":{"id":"w0133","w":"茶","lv":"1","gloss":"tea; tea plant","pron":"chá"},"w0141":{"id":"w0141","w":"谢谢","lv":"1","gloss":"to thank; thanks","pron":"xièxie"},"x0001":{"id":"x0001","w":"杯","lv":"1","gloss":"cup; glass (measure word for drinks)","pron":"bēi","bonus":true}},"concepts":{"tea":["w0133"],"water":["w0108"],"three":["w0003"],"four":["w0060"],"five":["w0020"],"hello":["w0028","w0072"],"thanks":["w0141"],"sorry":["w0079"],"bye":["w0034"]},"groups":{"courtesies":["hello","thanks","sorry"],"drinks":["tea","water"],"numbers_3_5":["three","four","five"]},"world":{"start":"street","currency":"¥","slotsPerDay":4,"startWallet":20,"foodPerDay":5,"rentPerWeek":50,"places":{"street":{"links":["noodle_shop"]},"noodle_shop":{"links":["street"]}},"npcs":{"wang":{"place":"street"},"cook":{"place":"noodle_shop"}}},"scenes":[{"id":"noodle-intro","place":"noodle_shop","npc":"cook","stage":1,"after":["street-hello"],"requires":{},"repeatable":false,"trustGain":1,"exchanges":[{"id":"greet","slots":{},"expect":{"action":"greet"},"hinges":[],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}}}},{"id":"job","slots":{},"expect":{"action":"accept"},"hinges":[],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"工作,好吗?","tokens":[{"start":0,"end":2,"word":"w0084"},{"start":3,"end":4,"word":"w0072"},{"start":4,"end":5,"word":"w0051"}],"meaning":"Want to work here?"},"reply":{"text":"好。","tokens":[{"start":0,"end":1,"word":"w0072"}],"meaning":"OK."}}}}]},{"id":"noodle-shift","place":"noodle_shop","npc":"cook","stage":1,"after":["noodle-intro"],"requires":{"trust":{"cook":1}},"repeatable":true,"trustGain":1,"exchanges":[{"id":"drink","slots":{"item":"drinks"},"expect":{"action":"fetch","item":"$item"},"hinges":["$item"],"pay":1,"missCost":1,"variants":{"item=tea":{"npc":{"text":"茶。","tokens":[{"start":0,"end":1,"word":"w0133"}],"meaning":"Tea."},"reply":{"text":"好,茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0133"}],"meaning":"OK, tea."}},"item=water":{"npc":{"text":"水。","tokens":[{"start":0,"end":1,"word":"w0108"}],"meaning":"Water."},"reply":{"text":"好,水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0108"}],"meaning":"OK, water."}}}},{"id":"order","slots":{"item":"drinks","count":"numbers_3_5"},"expect":{"action":"serve","item":"$item","count":"$count"},"hinges":["$item","$count"],"pay":3,"missCost":2,"variants":{"count=three|item=tea":{"npc":{"text":"三杯茶。","tokens":[{"start":0,"end":1,"word":"w0003"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0133"}],"meaning":"Three cups of tea."},"reply":{"text":"好,三杯茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0133"}],"meaning":"OK, three cups of tea."},"rephrase":{"text":"茶。三杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Tea. Three cups."}},"count=three|item=water":{"npc":{"text":"三杯水。","tokens":[{"start":0,"end":1,"word":"w0003"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0108"}],"meaning":"Three cups of water."},"reply":{"text":"好,三杯水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0108"}],"meaning":"OK, three cups of water."},"rephrase":{"text":"水。三杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Water. Three cups."}},"count=four|item=tea":{"npc":{"text":"四杯茶。","tokens":[{"start":0,"end":1,"word":"w0060"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0133"}],"meaning":"Four cups of tea."},"reply":{"text":"好,四杯茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0133"}],"meaning":"OK, four cups of tea."},"rephrase":{"text":"茶。四杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Tea. Four cups."}},"count=four|item=water":{"npc":{"text":"四杯水。","tokens":[{"start":0,"end":1,"word":"w0060"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0108"}],"meaning":"Four cups of water."},"reply":{"text":"好,四杯水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0108"}],"meaning":"OK, four cups of water."},"rephrase":{"text":"水。四杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Water. Four cups."}},"count=five|item=tea":{"npc":{"text":"五杯茶。","tokens":[{"start":0,"end":1,"word":"w0020"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0133"}],"meaning":"Five cups of tea."},"reply":{"text":"好,五杯茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0133"}],"meaning":"OK, five cups of tea."},"rephrase":{"text":"茶。五杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Tea. Five cups."}},"count=five|item=water":{"npc":{"text":"五杯水。","tokens":[{"start":0,"end":1,"word":"w0020"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0108"}],"meaning":"Five cups of water."},"reply":{"text":"好,五杯水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0108"}],"meaning":"OK, five cups of water."},"rephrase":{"text":"水。五杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Water. Five cups."}}}}]},{"id":"street-hello","place":"street","npc":"wang","stage":1,"after":[],"requires":{},"repeatable":false,"trustGain":1,"exchanges":[{"id":"hello","slots":{},"expect":{"action":"greet"},"hinges":["hello"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}}}},{"id":"polite","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"polite-again","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"bye","slots":{},"expect":{"action":"farewell"},"hinges":["bye"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}}]},{"id":"street-practice","place":"street","npc":"wang","stage":1,"after":["street-hello"],"requires":{},"repeatable":true,"trustGain":1,"exchanges":[{"id":"hello","slots":{},"expect":{"action":"greet"},"hinges":["hello"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}}}},{"id":"polite-1","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"polite-2","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"bye","slots":{},"expect":{"action":"farewell"},"hinges":["bye"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}}]}],"reactions":{"wrong-generic":{"text":"不是。","tokens":[{"start":0,"end":1,"word":"w0009"},{"start":1,"end":2,"word":"w0099"}],"meaning":"No."},"wrong-count":{"text":"几杯?","tokens":[{"start":0,"end":1,"word":"w0037"},{"start":1,"end":2,"word":"x0001"}],"meaning":"How many cups?"}},"learnerFtl":"# Text-game UI. Every id here is required by packages/tui (UI_KEYS).\n\nhud = Day { $day } · slot { $slot }/{ $slots } · { $currency }{ $wallet } · { $rank }\n\nrank-0 = Pidgin\nrank-1 = Getting By\nrank-2 = Conversational\nrank-3 = Fluent\nrank-4 = Silver Tongue\n\nmenu-title = What now?\nmenu-talk = Talk to { $npc }: { $scene }\nmenu-go = Go to { $place }\nmenu-sleep = Sleep (end the day)\nmenu-quit = Save and quit\n\nkeys-explore = [{ $keys }] choose · [q] quit\nkeys-pick = [{ $keys }] reply · [w] word help\nkeys-tiles = [{ $keys }] add tile · [⌫] undo · [enter] say it · [w] word help\nkeys-help = [{ $keys }] look up · [esc] back\nkeys-help-sentence = [{ $keys }] look up · [s] whole sentence · [esc] back\n\nhelp-title = Which word?\nhelp-sentence = The whole sentence\nhelp-in-replies = In the replies:\nreply-title = Your reply:\ntiles-answer = You say:\nyou = You\n\nmismatch = That's not what they asked for.\nrephrased = (slower)\nwallet-change = { $sign }{ $currency }{ $amount } ({ $reason })\nreason-wages = wages\nreason-mixup = mix-up\nreason-food = food\nreason-rent = rent\ntrust-up = { $npc } trusts you a little more ({ $trust }).\nscene-done = { $earned ->\n [0] Done.\n *[other] Done. You earned { $currency }{ $earned }.\n}\nunlocked = New: { $scene }\nrank-up = You're now: { $rank }\nday-ended = Day { $day } is over. You sleep.\nreject-unknown-scene = There's nobody here for that.\nreject-in-scene = Finish the conversation first.\nreject-wrong-place = They're not here.\nreject-locked = They're not ready to talk about that yet.\nreject-no-slots = You're out of time today. Sleep first.\nreject-stale-run = That conversation can't continue. Start it again.\nreject-no-pick = Choose a reply with the number keys.\nreject-bad-choice = There's no reply with that number.\nreject-no-tiles = Build your reply from the tiles.\nreject-bad-tile = There's no tile with that number.\nreject-not-linked = You can't get there from here.\nreject-unknown-word = That word isn't in the dictionary.\nnotice-read-only = Your progress can't be saved on this computer, so this session won't be kept.\nnotice-bad-save = Your save couldn't be read. It was kept as a backup and a new game started.\n\nresume-title = Your games:\nresume-item = Day { $day } · { $place } · { $currency }{ $wallet } · { $done ->\n [one] 1 scene done\n *[other] { $done } scenes done\n} · last played { $date }\nresume-ask = Which one? (number, or enter to cancel)\nresume-none = No saved games yet, so here's a new one.\n\n# The story. intro-1, intro-2, … open a new game ($currency, $wallet, $rent are available).\nintro-1 = Your phone is dead, your wallet is gone, and the bus that brought you here left an hour ago.\nintro-2 = You're standing on a street in a Chinese city with { $currency }{ $wallet } in your pocket. You know nobody, and nobody here speaks your language.\nintro-3 = Food costs money. Rent is { $currency }{ $rent }, due at the end of the week. To earn anything you'll need work, and for work you'll need words.\nintro-4 = On a bench by the road, an old man is watching you with open curiosity. Maybe start there.\n\nplace-street = Main Street\nplace-street-desc = Bikes, steam and shouting. An old man sits on a bench by the road. Across it, a noodle shop glows.\nplace-noodle_shop = Noodle Shop\nplace-noodle_shop-desc = Steam everywhere. The cook is shouting orders at nobody in particular.\n\nnpc-wang = Old Wang\nnpc-cook = Cook\n\n# Scene names for the menu; scene-<id>-start and scene-<id>-end are optional narration.\nscene-street-hello = Say hello\nscene-street-hello-start = The old man pats the bench beside him. \"Wang,\" he says, pointing at himself. He seems to have decided you need lessons: he says something, you answer. Use [w] when you're stuck.\nscene-street-hello-end = Old Wang beams. He points across the road at the noodle shop, mimes slurping noodles, then rubs his fingers together. Work. Money.\n\nscene-street-practice = Practise greetings\nscene-street-practice-start = Old Wang shuffles over to make room. Another lesson, then.\nscene-street-practice-end = Old Wang nods, satisfied, and goes back to watching the street.\n\nscene-noodle-intro = Ask about work\nscene-noodle-intro-start = The cook looks you up and down, wiping her hands on her apron.\nscene-noodle-intro-end = She throws you an apron. You have a job, sort of.\n\nscene-noodle-shift = Work a shift\nscene-noodle-shift-start = Orders fly across the counter. Keep up.\nscene-noodle-shift-end = The rush dies down. The cook counts coins into your hand.\n"}
|
|
1
|
+
{"id":"zh-china-en","typing":false,"words":{"w0003":{"id":"w0003","w":"三","lv":"1","gloss":"three; 3","pron":"sān"},"w0009":{"id":"w0009","w":"不","lv":"1","gloss":"no; not","pron":"bù"},"w0010":{"id":"w0010","w":"不客气","lv":"1","gloss":"you're welcome; don't mention it","pron":"bùkèqi"},"w0020":{"id":"w0020","w":"五","lv":"1","gloss":"five; 5","pron":"wǔ"},"w0028":{"id":"w0028","w":"你","lv":"1","gloss":"you (informal)","pron":"nǐ"},"w0034":{"id":"w0034","w":"再见","lv":"1","gloss":"goodbye","pron":"zàijiàn"},"w0037":{"id":"w0037","w":"几","lv":"1","gloss":"how much; how many","pron":"jǐ"},"w0051":{"id":"w0051","w":"吗","lv":"1","gloss":"(question particle for \"yes-no\" questions)","pron":"ma"},"w0060":{"id":"w0060","w":"四","lv":"1","gloss":"four; 4","pron":"sì"},"w0072":{"id":"w0072","w":"好","lv":"1","gloss":"good; appropriate; proper","pron":"hǎo"},"w0079":{"id":"w0079","w":"对不起","lv":"1","gloss":"I'm sorry; excuse me; I beg your pardon","pron":"duìbuqǐ"},"w0084":{"id":"w0084","w":"工作","lv":"1","gloss":"to work; (of a machine) to operate","pron":"gōngzuò"},"w0099":{"id":"w0099","w":"是","lv":"1","gloss":"to be (followed by substantives only); correct; right; true","pron":"shì"},"w0108":{"id":"w0108","w":"水","lv":"1","gloss":"water; river","pron":"shuǐ"},"w0112":{"id":"w0112","w":"没关系","lv":"1","gloss":"it doesn't matter","pron":"méiguānxi"},"w0133":{"id":"w0133","w":"茶","lv":"1","gloss":"tea; tea plant","pron":"chá"},"w0141":{"id":"w0141","w":"谢谢","lv":"1","gloss":"to thank; thanks","pron":"xièxie"},"x0001":{"id":"x0001","w":"杯","lv":"1","gloss":"cup; glass (measure word for drinks)","pron":"bēi","bonus":true}},"concepts":{"tea":["w0133"],"water":["w0108"],"three":["w0003"],"four":["w0060"],"five":["w0020"],"hello":["w0028","w0072"],"thanks":["w0141"],"sorry":["w0079"],"bye":["w0034"]},"groups":{"courtesies":["hello","thanks","sorry"],"drinks":["tea","water"],"numbers_3_5":["three","four","five"]},"world":{"start":"street","currency":"¥","slotsPerDay":4,"startWallet":20,"foodPerDay":5,"rentPerWeek":50,"places":{"street":{"links":["noodle_shop"]},"noodle_shop":{"links":["street"]}},"npcs":{"wang":{"place":"street"},"cook":{"place":"noodle_shop"}},"mentor":{"npc":"wang","after":"street-hello"}},"scenes":[{"id":"noodle-intro","place":"noodle_shop","npc":"cook","stage":1,"after":["street-hello"],"requires":{},"repeatable":false,"trustGain":1,"exchanges":[{"id":"greet","slots":{},"expect":{"action":"greet"},"hinges":[],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}}}},{"id":"job","slots":{},"expect":{"action":"accept"},"hinges":[],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"工作,好吗?","tokens":[{"start":0,"end":2,"word":"w0084"},{"start":3,"end":4,"word":"w0072"},{"start":4,"end":5,"word":"w0051"}],"meaning":"Want to work here?"},"reply":{"text":"好。","tokens":[{"start":0,"end":1,"word":"w0072"}],"meaning":"OK."}}}}]},{"id":"noodle-shift","place":"noodle_shop","npc":"cook","stage":1,"after":["noodle-intro"],"requires":{"trust":{"cook":1}},"repeatable":true,"trustGain":1,"exchanges":[{"id":"drink","slots":{"item":"drinks"},"expect":{"action":"fetch","item":"$item"},"hinges":["$item"],"pay":1,"missCost":1,"variants":{"item=tea":{"npc":{"text":"茶。","tokens":[{"start":0,"end":1,"word":"w0133"}],"meaning":"Tea."},"reply":{"text":"好,茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0133"}],"meaning":"OK, tea."}},"item=water":{"npc":{"text":"水。","tokens":[{"start":0,"end":1,"word":"w0108"}],"meaning":"Water."},"reply":{"text":"好,水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0108"}],"meaning":"OK, water."}}}},{"id":"order","slots":{"item":"drinks","count":"numbers_3_5"},"expect":{"action":"serve","item":"$item","count":"$count"},"hinges":["$item","$count"],"pay":3,"missCost":2,"variants":{"count=three|item=tea":{"npc":{"text":"三杯茶。","tokens":[{"start":0,"end":1,"word":"w0003"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0133"}],"meaning":"Three cups of tea."},"reply":{"text":"好,三杯茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0133"}],"meaning":"OK, three cups of tea."},"rephrase":{"text":"茶。三杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Tea. Three cups."}},"count=three|item=water":{"npc":{"text":"三杯水。","tokens":[{"start":0,"end":1,"word":"w0003"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0108"}],"meaning":"Three cups of water."},"reply":{"text":"好,三杯水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0108"}],"meaning":"OK, three cups of water."},"rephrase":{"text":"水。三杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Water. Three cups."}},"count=four|item=tea":{"npc":{"text":"四杯茶。","tokens":[{"start":0,"end":1,"word":"w0060"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0133"}],"meaning":"Four cups of tea."},"reply":{"text":"好,四杯茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0133"}],"meaning":"OK, four cups of tea."},"rephrase":{"text":"茶。四杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Tea. Four cups."}},"count=four|item=water":{"npc":{"text":"四杯水。","tokens":[{"start":0,"end":1,"word":"w0060"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0108"}],"meaning":"Four cups of water."},"reply":{"text":"好,四杯水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0108"}],"meaning":"OK, four cups of water."},"rephrase":{"text":"水。四杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Water. Four cups."}},"count=five|item=tea":{"npc":{"text":"五杯茶。","tokens":[{"start":0,"end":1,"word":"w0020"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0133"}],"meaning":"Five cups of tea."},"reply":{"text":"好,五杯茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0133"}],"meaning":"OK, five cups of tea."},"rephrase":{"text":"茶。五杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Tea. Five cups."}},"count=five|item=water":{"npc":{"text":"五杯水。","tokens":[{"start":0,"end":1,"word":"w0020"},{"start":1,"end":2,"word":"x0001"},{"start":2,"end":3,"word":"w0108"}],"meaning":"Five cups of water."},"reply":{"text":"好,五杯水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"},{"start":4,"end":5,"word":"w0108"}],"meaning":"OK, five cups of water."},"rephrase":{"text":"水。五杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"}],"meaning":"Water. Five cups."}}}}]},{"id":"street-hello","place":"street","npc":"wang","stage":1,"after":[],"requires":{},"repeatable":false,"trustGain":1,"exchanges":[{"id":"hello","slots":{},"expect":{"action":"greet"},"hinges":["hello"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}}}},{"id":"polite","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"polite-again","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"bye","slots":{},"expect":{"action":"farewell"},"hinges":["bye"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}}]},{"id":"street-practice","place":"street","npc":"wang","stage":1,"after":["street-hello"],"requires":{},"repeatable":true,"trustGain":1,"exchanges":[{"id":"hello","slots":{},"expect":{"action":"greet"},"hinges":["hello"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}}}},{"id":"polite-1","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"polite-2","slots":{"said":"courtesies"},"expect":{"action":"answer","said":"$said"},"hinges":["$said"],"pay":0,"missCost":0,"variants":{"said=hello":{"npc":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}],"meaning":"Hello!"}},"said=thanks":{"npc":{"text":"谢谢!","tokens":[{"start":0,"end":2,"word":"w0141"}],"meaning":"Thank you!"},"reply":{"text":"不客气!","tokens":[{"start":0,"end":3,"word":"w0010"}],"meaning":"You're welcome!"}},"said=sorry":{"npc":{"text":"对不起!","tokens":[{"start":0,"end":3,"word":"w0079"}],"meaning":"Sorry!"},"reply":{"text":"没关系!","tokens":[{"start":0,"end":3,"word":"w0112"}],"meaning":"That's all right!"}}}},{"id":"bye","slots":{},"expect":{"action":"farewell"},"hinges":["bye"],"pay":0,"missCost":0,"variants":{"":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}}]}],"reactions":{"wrong-generic":{"text":"不是。","tokens":[{"start":0,"end":1,"word":"w0009"},{"start":1,"end":2,"word":"w0099"}],"meaning":"No."},"wrong-count":{"text":"几杯?","tokens":[{"start":0,"end":1,"word":"w0037"},{"start":1,"end":2,"word":"x0001"}],"meaning":"How many cups?"}},"learnerFtl":"# Text-game UI. Every id here is required by packages/tui (UI_KEYS).\n\nhud = Day { $day } · slot { $slot }/{ $slots } · { $currency }{ $wallet } · { $rank }{ $rentLate ->\n [yes] { \" · rent due\" }\n *[no] {\"\"}\n}\n\nrank-0 = Pidgin\nrank-1 = Getting By\nrank-2 = Conversational\nrank-3 = Fluent\nrank-4 = Silver Tongue\n\nmenu-title = What now?\nmenu-talk = Talk to { $npc }: { $scene }\nmenu-go = Go to { $place }\nmenu-mentor = Ask { $npc } about the language\ncost-slot = { \" · 1 slot\" }\nmenu-sleep = Sleep (end the day)\nmenu-quit = Save and quit\n\nkeys-explore = [{ $keys }] choose · [n] notebook · [q] quit\nkeys-pick = [{ $keys }] reply · [w] word help · [n] notebook\nkeys-tiles = [{ $keys }] add · [⌫] undo · [enter] say · [w] help · [n] notebook\nkeys-help = [{ $keys }] look up · [esc] back\nkeys-help-sentence = [{ $keys }] look up · [s] whole sentence · [esc] back\n\nhelp-title = Which word?\nhelp-sentence = The whole sentence\nhelp-in-replies = In the replies:\nreply-title = Your reply:\ntiles-title = Your reply: tap the words in order, then press [enter].\ntiles-answer = You say:\nyou = You\n\nmismatch = That's not what they asked for.\nrephrased = (slower)\nwallet-change = { $sign }{ $currency }{ $amount } ({ $reason })\nreason-wages = wages\nreason-mixup = mix-up\nreason-food = food\nreason-rent = rent\ntrust-up = { $npc } trusts you a little more ({ $trust }).\nscene-done = { $earned ->\n [0] Done.\n *[other] Done. You earned { $currency }{ $earned }.\n}\nunlocked = New: { $scene }\nrank-up = You're now: { $rank }\nday-ended = Day { $day } is over. You sleep.\nreject-unknown-scene = There's nobody here for that.\nreject-in-scene = Finish the conversation first.\nreject-wrong-place = They're not here.\nreject-locked = They're not ready to talk about that yet.\nreject-no-slots = You're out of time today. Sleep first.\nreject-stale-run = That conversation can't continue. Start it again.\nreject-no-pick = Choose a reply with the number keys.\nreject-bad-choice = There's no reply with that number.\nreject-no-tiles = Build your reply from the tiles.\nreject-bad-tile = There's no tile with that number.\nreject-not-linked = You can't get there from here.\nreject-unknown-word = That word isn't in the dictionary.\nnotice-read-only = Your progress can't be saved on this computer, so this session won't be kept.\nnotice-bad-save = Your save couldn't be read. It was kept as a backup and a new game started.\n\nresume-title = Your games:\nresume-item = Day { $day } · { $place } · { $currency }{ $wallet } · { $done ->\n [one] 1 scene done\n *[other] { $done } scenes done\n} · last played { $date }\nresume-ask = Which one? (number, or enter to cancel)\nresume-none = No saved games yet, so here's a new one.\nreject-no-mentor = There's nobody here to explain things.\nnote-hint = { $npc } seems to have something to tell you.\nmentor-nothing = { $npc } has nothing new to explain today.\n\nkeys-notebook = [↑↓] scroll · [esc] back\nnotebook-progress = Stage { $stage }: { $known } of { $total } words known · { $heard } heard\nnotebook-empty = Nothing yet. Words you hear are written down here.\nnotebook-elsewhere = Heard elsewhere\nnotebook-notes = Notes\nexport-none = There's no saved game to export yet.\nimport-bad = That line isn't a saved game for this course ({ $reason }).\nimport-done = Added: { $game }. Run the game to continue it.\n\n# Browser page controls (packages/tui-web).\nweb-new = New game\nweb-games = Games\nweb-games-none = No saved games yet.\nweb-export = Export\nweb-export-hint = This line is your whole game. Paste it into Import on another device, or into the terminal game with: npx silver-tongue --import -\nweb-copy = Copy\nweb-copied = Copied\nweb-import = Import\nweb-import-hint = Paste a line from Export, here or from the terminal game. It is added as a new game; nothing is overwritten.\nweb-import-go = Add game\nweb-close = Close\nweb-saved = Your game is saved. You can close this tab, or keep playing.\n\n# The story. intro-1, intro-2, … open a new game ($currency, $wallet, $rent are available).\nintro-1 = Your phone is dead, your wallet is gone, and the bus that brought you here left an hour ago.\nintro-2 = You're standing on a street in a Chinese city with { $currency }{ $wallet } in your pocket. You know nobody, and nobody here speaks your language.\nintro-3 = Food costs money. Rent is { $currency }{ $rent }, due at the end of the week. To earn anything you'll need work, and for work you'll need words.\nintro-4 = On a bench by the road, an old man is watching you with open curiosity. Maybe start there.\n\nplace-street = Main Street\nplace-street-desc = Bikes, steam and shouting. An old man sits on a bench by the road. Across it, a noodle shop glows.\nplace-noodle_shop = Noodle Shop\nplace-noodle_shop-desc = Steam everywhere. The cook is shouting orders at nobody in particular.\n\nnpc-wang = Old Wang\nnpc-cook = Cook\n\n# Scene names for the menu; scene-<id>-start and scene-<id>-end are optional narration.\nscene-street-hello = Say hello\nscene-street-hello-start = The old man pats the bench beside him. \"Wang,\" he says, pointing at himself. He seems to have decided you need lessons: he says something, you answer. Use [w] when you're stuck.\nscene-street-hello-end = Old Wang beams. He points across the road at the noodle shop, mimes slurping noodles, then rubs his fingers together. Work. Money.\n\nscene-street-practice = Practise greetings\nscene-street-practice-start = Old Wang shuffles over to make room. Another lesson, then.\nscene-street-practice-end = Old Wang nods, satisfied, and goes back to watching the street.\n\nscene-noodle-intro = Ask about work\nscene-noodle-intro-start = The cook looks you up and down, wiping her hands on her apron.\nscene-noodle-intro-end = She throws you an apron. You have a job, sort of.\n\nscene-noodle-shift = Work a shift\nscene-noodle-shift-start = Orders fly across the counter. Keep up.\nscene-noodle-shift-end = The rush dies down. The cook counts coins into your hand.\n\n# What a reply does (action-<name>), and on a mix-up what was asked (asked-<name>).\n# Concept values arrive as learner names: $item = \"tea\", $count = \"three\".\naction-fetch = You bring { $item }.\nasked-fetch = They wanted { $item }.\naction-serve = You set down { $count } cups of { $item }.\nasked-serve = They wanted { $count } cups of { $item }.\n\n# Usage notes Old Wang explains (languages/zh/notes.json). note-<id>-title and note-<id>.\n\nnote-bukeqi-title = 不客气, \"you're welcome\"\nnote-bukeqi = 不客气 (bùkèqi) literally means \"don't be polite\": no need to stand on ceremony. It's the usual answer to 谢谢. Between close friends, people often skip 谢谢 altogether; thanking a friend too much can sound distant.\n\nnote-hao-ma-title = 好 and 吗\nnote-hao-ma = 好 (hǎo) means \"good\", and on its own it's how you agree: \"OK\". Put 吗 (ma) at the end of a sentence and it becomes a yes-or-no question: 工作,好吗? is \"Work, OK?\" To say yes, answer with the key word: 好。\n\nnote-bei-title = 杯, a measure word\nnote-bei = Chinese doesn't say \"three teas\". A measure word goes between a number and a thing: 三杯茶 is \"three cups of tea\". 杯 (bēi) is the one for cups and glasses. There are many measure words; 个 (gè) is the everyday all-purpose one.\n","conceptNames":{"tea":"tea","water":"water","three":"three","four":"four","five":"five","hello":"Hello","thanks":"Thank you","sorry":"Sorry","bye":"Goodbye"},"stageWords":{"1":["w0001","w0002","w0003","w0004","w0005","w0006","w0007","w0008","w0009","w0010","w0011","w0012","w0013","w0014","w0015","w0016","w0017","w0018","w0019","w0020","w0021","w0022","w0023","w0024","w0025","w0026","w0027","w0028","w0029","w0030","w0031","w0032","w0033","w0034","w0035","w0036","w0037","w0038","w0039","w0040","w0041","w0042","w0043","w0044","w0045","w0046","w0047","w0048","w0049","w0050","w0051","w0052","w0053","w0054","w0055","w0056","w0057","w0058","w0059","w0060","w0061","w0062","w0063","w0064","w0065","w0066","w0067","w0068","w0069","w0070","w0071","w0072","w0073","w0074","w0075","w0076","w0077","w0078","w0079","w0080","w0081","w0082","w0083","w0084","w0085","w0086","w0087","w0088","w0089","w0090","w0091","w0092","w0093","w0094","w0095","w0096","w0097","w0098","w0099","w0100","w0101","w0102","w0103","w0104","w0105","w0106","w0107","w0108","w0109","w0110","w0111","w0112","w0113","w0114","w0115","w0116","w0117","w0118","w0119","w0120","w0121","w0122","w0123","w0124","w0125","w0126","w0127","w0128","w0129","w0130","w0131","w0132","w0133","w0134","w0135","w0136","w0137","w0138","w0139","w0140","w0141","w0142","w0143","w0144","w0145","w0146","w0147","w0148","w0149","w0150"],"2":["w0151","w0152","w0153","w0154","w0155","w0156","w0157","w0158","w0159","w0160","w0161","w0162","w0163","w0164","w0165","w0166","w0167","w0168","w0169","w0170","w0171","w0172","w0173","w0174","w0175","w0176","w0177","w0178","w0179","w0180","w0181","w0182","w0183","w0184","w0185","w0186","w0187","w0188","w0189","w0190","w0191","w0192","w0193","w0194","w0195","w0196","w0197","w0198","w0199","w0200","w0201","w0202","w0203","w0204","w0205","w0206","w0207","w0208","w0209","w0210","w0211","w0212","w0213","w0214","w0215","w0216","w0217","w0218","w0219","w0220","w0221","w0222","w0223","w0224","w0225","w0226","w0227","w0228","w0229","w0230","w0231","w0232","w0233","w0234","w0235","w0236","w0237","w0238","w0239","w0240","w0241","w0242","w0243","w0244","w0245","w0246","w0247","w0248","w0249","w0250","w0251","w0252","w0253","w0254","w0255","w0256","w0257","w0258","w0259","w0260","w0261","w0262","w0263","w0264","w0265","w0266","w0267","w0268","w0269","w0270","w0271","w0272","w0273","w0274","w0275","w0276","w0277","w0278","w0279","w0280","w0281","w0282","w0283","w0284","w0285","w0286","w0287","w0288","w0289","w0290","w0291","w0292","w0293","w0294","w0295","w0296","w0297"],"3":["w0298","w0299","w0300","w0301","w0302","w0303","w0304","w0305","w0306","w0307","w0308","w0309","w0310","w0311","w0312","w0313","w0314","w0315","w0316","w0317","w0318","w0319","w0320","w0321","w0322","w0323","w0324","w0325","w0326","w0327","w0328","w0329","w0330","w0331","w0332","w0333","w0334","w0335","w0336","w0337","w0338","w0339","w0340","w0341","w0342","w0343","w0344","w0345","w0346","w0347","w0348","w0349","w0350","w0351","w0352","w0353","w0354","w0355","w0356","w0357","w0358","w0359","w0360","w0361","w0362","w0363","w0364","w0365","w0366","w0367","w0368","w0369","w0370","w0371","w0372","w0373","w0374","w0375","w0376","w0377","w0378","w0379","w0380","w0381","w0382","w0383","w0384","w0385","w0386","w0387","w0388","w0389","w0390","w0391","w0392","w0393","w0394","w0395","w0396","w0397","w0398","w0399","w0400","w0401","w0402","w0403","w0404","w0405","w0406","w0407","w0408","w0409","w0410","w0411","w0412","w0413","w0414","w0415","w0416","w0417","w0418","w0419","w0420","w0421","w0422","w0423","w0424","w0425","w0426","w0427","w0428","w0429","w0430","w0431","w0432","w0433","w0434","w0435","w0436","w0437","w0438","w0439","w0440","w0441","w0442","w0443","w0444","w0445","w0446","w0447","w0448","w0449","w0450","w0451","w0452","w0453","w0454","w0455","w0456","w0457","w0458","w0459","w0460","w0461","w0462","w0463","w0464","w0465","w0466","w0467","w0468","w0469","w0470","w0471","w0472","w0473","w0474","w0475","w0476","w0477","w0478","w0479","w0480","w0481","w0482","w0483","w0484","w0485","w0486","w0487","w0488","w0489","w0490","w0491","w0492","w0493","w0494","w0495","w0496","w0497","w0498","w0499","w0500","w0501","w0502","w0503","w0504","w0505","w0506","w0507","w0508","w0509","w0510","w0511","w0512","w0513","w0514","w0515","w0516","w0517","w0518","w0519","w0520","w0521","w0522","w0523","w0524","w0525","w0526","w0527","w0528","w0529","w0530","w0531","w0532","w0533","w0534","w0535","w0536","w0537","w0538","w0539","w0540","w0541","w0542","w0543","w0544","w0545","w0546","w0547","w0548","w0549","w0550","w0551","w0552","w0553","w0554","w0555","w0556","w0557","w0558","w0559","w0560","w0561","w0562","w0563","w0564","w0565","w0566","w0567","w0568","w0569","w0570","w0571","w0572","w0573","w0574","w0575","w0576","w0577","w0578","w0579","w0580","w0581","w0582","w0583","w0584","w0585","w0586","w0587","w0588","w0589","w0590","w0591","w0592","w0593","w0594","w0595"],"4":["w0596","w0597","w0598","w0599","w0600","w0601","w0602","w0603","w0604","w0605","w0606","w0607","w0608","w0609","w0610","w0611","w0612","w0613","w0614","w0615","w0616","w0617","w0618","w0619","w0620","w0621","w0622","w0623","w0624","w0625","w0626","w0627","w0628","w0629","w0630","w0631","w0632","w0633","w0634","w0635","w0636","w0637","w0638","w0639","w0640","w0641","w0642","w0643","w0644","w0645","w0646","w0647","w0648","w0649","w0650","w0651","w0652","w0653","w0654","w0655","w0656","w0657","w0658","w0659","w0660","w0661","w0662","w0663","w0664","w0665","w0666","w0667","w0668","w0669","w0670","w0671","w0672","w0673","w0674","w0675","w0676","w0677","w0678","w0679","w0680","w0681","w0682","w0683","w0684","w0685","w0686","w0687","w0688","w0689","w0690","w0691","w0692","w0693","w0694","w0695","w0696","w0697","w0698","w0699","w0700","w0701","w0702","w0703","w0704","w0705","w0706","w0707","w0708","w0709","w0710","w0711","w0712","w0713","w0714","w0715","w0716","w0717","w0718","w0719","w0720","w0721","w0722","w0723","w0724","w0725","w0726","w0727","w0728","w0729","w0730","w0731","w0732","w0733","w0734","w0735","w0736","w0737","w0738","w0739","w0740","w0741","w0742","w0743","w0744","w0745","w0746","w0747","w0748","w0749","w0750","w0751","w0752","w0753","w0754","w0755","w0756","w0757","w0758","w0759","w0760","w0761","w0762","w0763","w0764","w0765","w0766","w0767","w0768","w0769","w0770","w0771","w0772","w0773","w0774","w0775","w0776","w0777","w0778","w0779","w0780","w0781","w0782","w0783","w0784","w0785","w0786","w0787","w0788","w0789","w0790","w0791","w0792","w0793","w0794","w0795","w0796","w0797","w0798","w0799","w0800","w0801","w0802","w0803","w0804","w0805","w0806","w0807","w0808","w0809","w0810","w0811","w0812","w0813","w0814","w0815","w0816","w0817","w0818","w0819","w0820","w0821","w0822","w0823","w0824","w0825","w0826","w0827","w0828","w0829","w0830","w0831","w0832","w0833","w0834","w0835","w0836","w0837","w0838","w0839","w0840","w0841","w0842","w0843","w0844","w0845","w0846","w0847","w0848","w0849","w0850","w0851","w0852","w0853","w0854","w0855","w0856","w0857","w0858","w0859","w0860","w0861","w0862","w0863","w0864","w0865","w0866","w0867","w0868","w0869","w0870","w0871","w0872","w0873","w0874","w0875","w0876","w0877","w0878","w0879","w0880","w0881","w0882","w0883","w0884","w0885","w0886","w0887","w0888","w0889","w0890","w0891","w0892","w0893","w0894","w0895","w0896","w0897","w0898","w0899","w0900","w0901","w0902","w0903","w0904","w0905","w0906","w0907","w0908","w0909","w0910","w0911","w0912","w0913","w0914","w0915","w0916","w0917","w0918","w0919","w0920","w0921","w0922","w0923","w0924","w0925","w0926","w0927","w0928","w0929","w0930","w0931","w0932","w0933","w0934","w0935","w0936","w0937","w0938","w0939","w0940","w0941","w0942","w0943","w0944","w0945","w0946","w0947","w0948","w0949","w0950","w0951","w0952","w0953","w0954","w0955","w0956","w0957","w0958","w0959","w0960","w0961","w0962","w0963","w0964","w0965","w0966","w0967","w0968","w0969","w0970","w0971","w0972","w0973","w0974","w0975","w0976","w0977","w0978","w0979","w0980","w0981","w0982","w0983","w0984","w0985","w0986","w0987","w0988","w0989","w0990","w0991","w0992","w0993","w0994","w0995","w0996","w0997","w0998","w0999","w1000","w1001","w1002","w1003","w1004","w1005","w1006","w1007","w1008","w1009","w1010","w1011","w1012","w1013","w1014","w1015","w1016","w1017","w1018","w1019","w1020","w1021","w1022","w1023","w1024","w1025","w1026","w1027","w1028","w1029","w1030","w1031","w1032","w1033","w1034","w1035","w1036","w1037","w1038","w1039","w1040","w1041","w1042","w1043","w1044","w1045","w1046","w1047","w1048","w1049","w1050","w1051","w1052","w1053","w1054","w1055","w1056","w1057","w1058","w1059","w1060","w1061","w1062","w1063","w1064","w1065","w1066","w1067","w1068","w1069","w1070","w1071","w1072","w1073","w1074","w1075","w1076","w1077","w1078","w1079","w1080","w1081","w1082","w1083","w1084","w1085","w1086","w1087","w1088","w1089","w1090","w1091","w1092","w1093","w1094","w1095","w1096","w1097","w1098","w1099","w1100","w1101","w1102","w1103","w1104","w1105","w1106","w1107","w1108","w1109","w1110","w1111","w1112","w1113","w1114","w1115","w1116","w1117","w1118","w1119","w1120","w1121","w1122","w1123","w1124","w1125","w1126","w1127","w1128","w1129","w1130","w1131","w1132","w1133","w1134","w1135","w1136","w1137","w1138","w1139","w1140","w1141","w1142","w1143","w1144","w1145","w1146","w1147","w1148","w1149","w1150","w1151","w1152","w1153","w1154","w1155","w1156","w1157","w1158","w1159","w1160","w1161","w1162","w1163","w1164","w1165","w1166","w1167","w1168","w1169","w1170","w1171","w1172","w1173","w1174","w1175","w1176","w1177","w1178","w1179","w1180","w1181","w1182","w1183","w1184","w1185","w1186","w1187","w1188","w1189","w1190","w1191","w1192","w1193"]},"notes":[{"id":"bukeqi","trigger":{"scene":"street-hello"}},{"id":"hao-ma","trigger":{"scene":"noodle-intro"}},{"id":"bei","trigger":{"word":"x0001"}}]}
|
package/dist/silver-tongue.mjs
CHANGED
|
@@ -447,7 +447,7 @@ var require_bundle = __commonJS({
|
|
|
447
447
|
* @param locales - Used to instantiate `Intl` formatters used by translations.
|
|
448
448
|
* @param options - Optional configuration for the bundle.
|
|
449
449
|
*/
|
|
450
|
-
constructor(locales, { functions, useIsolating = true, transform = (v) => v } = {}) {
|
|
450
|
+
constructor(locales, { functions, useIsolating = true, transform: transform2 = (v) => v } = {}) {
|
|
451
451
|
this._terms = /* @__PURE__ */ new Map();
|
|
452
452
|
this._messages = /* @__PURE__ */ new Map();
|
|
453
453
|
this.locales = Array.isArray(locales) ? locales : [locales];
|
|
@@ -457,7 +457,7 @@ var require_bundle = __commonJS({
|
|
|
457
457
|
...functions
|
|
458
458
|
};
|
|
459
459
|
this._useIsolating = useIsolating;
|
|
460
|
-
this._transform =
|
|
460
|
+
this._transform = transform2;
|
|
461
461
|
this._intls = getMemoizerForLocale(locales);
|
|
462
462
|
}
|
|
463
463
|
/**
|
|
@@ -945,7 +945,8 @@ var REJECT_REASONS = [
|
|
|
945
945
|
"no-tiles",
|
|
946
946
|
"bad-tile",
|
|
947
947
|
"not-linked",
|
|
948
|
-
"unknown-word"
|
|
948
|
+
"unknown-word",
|
|
949
|
+
"no-mentor"
|
|
949
950
|
];
|
|
950
951
|
var WALLET_REASONS = ["wages", "mixup", "food", "rent"];
|
|
951
952
|
|
|
@@ -1093,7 +1094,9 @@ function newGame(course2) {
|
|
|
1093
1094
|
trust: {},
|
|
1094
1095
|
words: {},
|
|
1095
1096
|
scenesDone: {},
|
|
1096
|
-
run: null
|
|
1097
|
+
run: null,
|
|
1098
|
+
notes: { ready: [], read: [] },
|
|
1099
|
+
log: []
|
|
1097
1100
|
};
|
|
1098
1101
|
}
|
|
1099
1102
|
|
|
@@ -1145,9 +1148,16 @@ function chooseCombo(ctx, ex) {
|
|
|
1145
1148
|
}
|
|
1146
1149
|
return combo;
|
|
1147
1150
|
}
|
|
1151
|
+
function hear(ctx, line) {
|
|
1152
|
+
for (const t of line.tokens) {
|
|
1153
|
+
setWord(ctx, t.word, recordSeen);
|
|
1154
|
+
const rec = ctx.state.words[t.word];
|
|
1155
|
+
if (!rec.first) rec.first = { line: line.text, place: ctx.state.place };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1148
1158
|
function speak(ctx, npc, line) {
|
|
1149
1159
|
ctx.ev.push({ type: "lineSpoken", npc, line });
|
|
1150
|
-
|
|
1160
|
+
hear(ctx, line);
|
|
1151
1161
|
}
|
|
1152
1162
|
function pickOptions(ctx, ex, combo) {
|
|
1153
1163
|
const rightKey = comboKey(combo);
|
|
@@ -1199,7 +1209,11 @@ function beginExchange(ctx, scene, index) {
|
|
|
1199
1209
|
const ex = scene.exchanges[index];
|
|
1200
1210
|
const combo = chooseCombo(ctx, ex);
|
|
1201
1211
|
speak(ctx, scene.npc, ex.variants[comboKey(combo)].npc);
|
|
1202
|
-
const states = hingeWords(ctx, ex, combo).map((w) =>
|
|
1212
|
+
const states = hingeWords(ctx, ex, combo).map((w) => {
|
|
1213
|
+
const rec = ctx.state.words[w];
|
|
1214
|
+
const st = wordState(rec, ctx.now);
|
|
1215
|
+
return st === "shaky" && rec && rec.right === 0 ? "met" : st;
|
|
1216
|
+
});
|
|
1203
1217
|
const mode = replyModeFor(states, ctx.course.typing);
|
|
1204
1218
|
run.exchange = index;
|
|
1205
1219
|
run.combo = combo;
|
|
@@ -1220,7 +1234,14 @@ function finishScene(ctx, scene) {
|
|
|
1220
1234
|
function resolve(ctx, scene, ex, chosen, diff, tilesWrong = false) {
|
|
1221
1235
|
const run = ctx.state.run;
|
|
1222
1236
|
const matched = diff.length === 0 && !tilesWrong;
|
|
1223
|
-
ctx.ev.push({
|
|
1237
|
+
ctx.ev.push({
|
|
1238
|
+
type: "actionPerformed",
|
|
1239
|
+
action: resolveParams(ex.expect, chosen),
|
|
1240
|
+
expected: resolveParams(ex.expect, run.combo),
|
|
1241
|
+
matched,
|
|
1242
|
+
diff,
|
|
1243
|
+
tilesWrong
|
|
1244
|
+
});
|
|
1224
1245
|
const hinges = hingeWords(ctx, ex, run.combo);
|
|
1225
1246
|
if (matched) {
|
|
1226
1247
|
for (const w of hinges) setWord(ctx, w, recordRight);
|
|
@@ -1235,11 +1256,16 @@ function resolve(ctx, scene, ex, chosen, diff, tilesWrong = false) {
|
|
|
1235
1256
|
ctx.ev.push(...changeWallet(ctx.state, -ex.missCost, "mixup"));
|
|
1236
1257
|
const reaction = diff.map((d) => `wrong-${d}`).find((r) => ctx.course.reactions[r]) ?? "wrong-generic";
|
|
1237
1258
|
ctx.ev.push({ type: "npcReacted", npc: scene.npc, reaction, line: ctx.course.reactions[reaction] });
|
|
1259
|
+
if (run.mode === "tiles" && run.misses >= 2) {
|
|
1260
|
+
run.mode = "pick";
|
|
1261
|
+
run.options = pickOptions(ctx, ex, run.combo);
|
|
1262
|
+
run.tiles = [];
|
|
1263
|
+
}
|
|
1238
1264
|
if (run.misses >= 2) {
|
|
1239
1265
|
const v = ex.variants[comboKey(run.combo)];
|
|
1240
1266
|
const line = v.rephrase ?? v.npc;
|
|
1241
1267
|
ctx.ev.push({ type: "lineRephrased", npc: scene.npc, line, slow: !v.rephrase });
|
|
1242
|
-
|
|
1268
|
+
hear(ctx, line);
|
|
1243
1269
|
}
|
|
1244
1270
|
emitOptions(ctx, ex);
|
|
1245
1271
|
}
|
|
@@ -1310,6 +1336,7 @@ var isStrings = (x) => Array.isArray(x) && x.every((s) => typeof s === "string")
|
|
|
1310
1336
|
var allValues = (o, ok) => Object.values(o).every(ok);
|
|
1311
1337
|
function isWordRecord(x) {
|
|
1312
1338
|
if (!isObj(x) || typeof x.lapsed !== "boolean") return false;
|
|
1339
|
+
if (x.first !== void 0 && !(isObj(x.first) && typeof x.first.line === "string" && typeof x.first.place === "string")) return false;
|
|
1313
1340
|
return ["right", "wrong", "streak", "helps", "firstSeen", "lastSeen"].every((k) => isCount(x[k]));
|
|
1314
1341
|
}
|
|
1315
1342
|
function isRunShape(x) {
|
|
@@ -1318,6 +1345,10 @@ function isRunShape(x) {
|
|
|
1318
1345
|
if (!["pick", "tiles", "type"].includes(x.mode) || !isStrings(x.options) || !isStrings(x.tiles)) return false;
|
|
1319
1346
|
return ["exchange", "misses", "earned", "mixups"].every((k) => isCount(x[k]));
|
|
1320
1347
|
}
|
|
1348
|
+
var INPUT_TYPES = /* @__PURE__ */ new Set(["goTo", "startScene", "reply", "replyTiles", "helpWord", "visitMentor", "sleep"]);
|
|
1349
|
+
function isLogEntry(x) {
|
|
1350
|
+
return isObj(x) && isCount(x.t) && isCount(x.day) && isCount(x.slot) && isObj(x.input) && INPUT_TYPES.has(x.input.type);
|
|
1351
|
+
}
|
|
1321
1352
|
function tilesCover(tiles, pieces2) {
|
|
1322
1353
|
const left = [...tiles];
|
|
1323
1354
|
return pieces2.every((p) => {
|
|
@@ -1352,12 +1383,45 @@ function parseSave(raw, course2) {
|
|
|
1352
1383
|
if (!isObj(data.scenesDone) || !allValues(data.scenesDone, isCount)) return { ok: false, reason: "bad-scenesDone" };
|
|
1353
1384
|
if (!isObj(data.words) || !allValues(data.words, isWordRecord)) return { ok: false, reason: "bad-words" };
|
|
1354
1385
|
if (data.run !== null && !isRunShape(data.run)) return { ok: false, reason: "bad-run" };
|
|
1386
|
+
if (!("notes" in data)) data.notes = { ready: [], read: [] };
|
|
1387
|
+
if (!("log" in data)) data.log = [];
|
|
1388
|
+
if (!isObj(data.notes) || !isStrings(data.notes.ready) || !isStrings(data.notes.read)) return { ok: false, reason: "bad-notes" };
|
|
1389
|
+
if (!Array.isArray(data.log) || !data.log.every(isLogEntry)) return { ok: false, reason: "bad-log" };
|
|
1390
|
+
const noteIds = new Set(course2.notes.map((n) => n.id));
|
|
1391
|
+
const keep = (ids) => [...new Set(ids)].filter((id) => noteIds.has(id));
|
|
1392
|
+
data.notes = { ready: keep(data.notes.ready), read: keep(data.notes.read) };
|
|
1355
1393
|
const state2 = data;
|
|
1356
1394
|
if (state2.run && !runFits(state2.run, course2)) state2.run = null;
|
|
1357
1395
|
return { ok: true, state: state2 };
|
|
1358
1396
|
}
|
|
1359
1397
|
|
|
1398
|
+
// ../core/src/mentor.ts
|
|
1399
|
+
function mentorAvailable(course2, state2) {
|
|
1400
|
+
const m = course2.world.mentor;
|
|
1401
|
+
if (!m) return false;
|
|
1402
|
+
return state2.place === course2.world.npcs[m.npc]?.place && (state2.scenesDone[m.after] ?? 0) > 0;
|
|
1403
|
+
}
|
|
1404
|
+
function triggered(note, state2) {
|
|
1405
|
+
return "word" in note.trigger ? !!state2.words[note.trigger.word]?.first : (state2.scenesDone[note.trigger.scene] ?? 0) > 0;
|
|
1406
|
+
}
|
|
1407
|
+
function newlyTriggered(course2, state2) {
|
|
1408
|
+
const known = /* @__PURE__ */ new Set([...state2.notes.ready, ...state2.notes.read]);
|
|
1409
|
+
return course2.notes.filter((n) => !known.has(n.id) && triggered(n, state2)).map((n) => n.id);
|
|
1410
|
+
}
|
|
1411
|
+
function visitMentor(ctx) {
|
|
1412
|
+
const { course: course2, state: state2 } = ctx;
|
|
1413
|
+
if (state2.run) return reject(ctx, "in-scene");
|
|
1414
|
+
if (!mentorAvailable(course2, state2)) return reject(ctx, "no-mentor");
|
|
1415
|
+
if (state2.slot >= course2.world.slotsPerDay) return reject(ctx, "no-slots");
|
|
1416
|
+
state2.slot += 1;
|
|
1417
|
+
const waiting = new Set(state2.notes.ready);
|
|
1418
|
+
const notes = course2.notes.filter((n) => waiting.has(n.id)).map((n) => n.id);
|
|
1419
|
+
state2.notes = { ready: [], read: [...state2.notes.read, ...notes] };
|
|
1420
|
+
ctx.ev.push({ type: "mentorVisited", npc: course2.world.mentor.npc, notes });
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1360
1423
|
// ../core/src/core.ts
|
|
1424
|
+
var LOG_LIMIT = 500;
|
|
1361
1425
|
function handle(ctx, input) {
|
|
1362
1426
|
const { course: course2, state: state2 } = ctx;
|
|
1363
1427
|
switch (input.type) {
|
|
@@ -1376,6 +1440,8 @@ function handle(ctx, input) {
|
|
|
1376
1440
|
case "helpWord":
|
|
1377
1441
|
if (!course2.words[input.word]) return reject(ctx, "unknown-word");
|
|
1378
1442
|
return setWord(ctx, input.word, recordHelp);
|
|
1443
|
+
case "visitMentor":
|
|
1444
|
+
return visitMentor(ctx);
|
|
1379
1445
|
case "sleep":
|
|
1380
1446
|
if (state2.run) return reject(ctx, "in-scene");
|
|
1381
1447
|
ctx.ev.push(...endDay(course2, state2));
|
|
@@ -1384,7 +1450,7 @@ function handle(ctx, input) {
|
|
|
1384
1450
|
}
|
|
1385
1451
|
function createCore(course2, initial, deps) {
|
|
1386
1452
|
const wordIds = Object.keys(course2.words);
|
|
1387
|
-
let state2 = initial;
|
|
1453
|
+
let state2 = { ...initial, notes: initial.notes ?? { ready: [], read: [] }, log: initial.log ?? [] };
|
|
1388
1454
|
return {
|
|
1389
1455
|
get state() {
|
|
1390
1456
|
return state2;
|
|
@@ -1394,10 +1460,15 @@ function createCore(course2, initial, deps) {
|
|
|
1394
1460
|
const ctx = { course: course2, state: structuredClone(state2), now, rng: deps.rng, ev: [] };
|
|
1395
1461
|
handle(ctx, input);
|
|
1396
1462
|
if (ctx.ev.some((e) => e.type === "inputRejected")) return ctx.ev;
|
|
1463
|
+
ctx.state.log = [...ctx.state.log, { t: now, day: state2.day, slot: state2.slot, input }].slice(-LOG_LIMIT);
|
|
1397
1464
|
const before = new Set(availableSceneIds(course2, state2));
|
|
1398
1465
|
for (const id of availableSceneIds(course2, ctx.state)) {
|
|
1399
1466
|
if (!before.has(id)) ctx.ev.push({ type: "unlocked", scene: id });
|
|
1400
1467
|
}
|
|
1468
|
+
for (const note of newlyTriggered(course2, ctx.state)) {
|
|
1469
|
+
ctx.state.notes.ready.push(note);
|
|
1470
|
+
ctx.ev.push({ type: "noteReady", note });
|
|
1471
|
+
}
|
|
1401
1472
|
const rank = rankFor(ctx.state.words, wordIds, now);
|
|
1402
1473
|
if (rank !== rankFor(state2.words, wordIds, now)) ctx.ev.push({ type: "rankChanged", rank });
|
|
1403
1474
|
state2 = ctx.state;
|
|
@@ -1406,6 +1477,20 @@ function createCore(course2, initial, deps) {
|
|
|
1406
1477
|
};
|
|
1407
1478
|
}
|
|
1408
1479
|
|
|
1480
|
+
// ../tui/src/ansi.ts
|
|
1481
|
+
var COLORS = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36 };
|
|
1482
|
+
function toAnsi(line) {
|
|
1483
|
+
return line.map((s) => {
|
|
1484
|
+
const codes = [
|
|
1485
|
+
...s.bold ? [1] : [],
|
|
1486
|
+
...s.dim ? [2] : [],
|
|
1487
|
+
...s.underline ? [4] : [],
|
|
1488
|
+
...s.color ? [COLORS[s.color]] : []
|
|
1489
|
+
];
|
|
1490
|
+
return codes.length ? `\x1B[${codes.join(";")}m${s.text}\x1B[0m` : s.text;
|
|
1491
|
+
}).join("");
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1409
1494
|
// ../tui/src/width.ts
|
|
1410
1495
|
var isControl = (cp) => cp < 32 || cp >= 127 && cp < 160;
|
|
1411
1496
|
function charWidth(cp) {
|
|
@@ -1502,7 +1587,7 @@ function makeText(ftl, locale = "en") {
|
|
|
1502
1587
|
return Object.assign(t, { has: (id) => !!bundle.getMessage(id)?.value });
|
|
1503
1588
|
}
|
|
1504
1589
|
var UI_KEYS = {
|
|
1505
|
-
hud: ["day", "slot", "slots", "currency", "wallet", "rank"],
|
|
1590
|
+
hud: ["day", "slot", "slots", "currency", "wallet", "rank", "rentLate"],
|
|
1506
1591
|
"rank-0": [],
|
|
1507
1592
|
"rank-1": [],
|
|
1508
1593
|
"rank-2": [],
|
|
@@ -1511,17 +1596,27 @@ var UI_KEYS = {
|
|
|
1511
1596
|
"menu-title": [],
|
|
1512
1597
|
"menu-talk": ["npc", "scene"],
|
|
1513
1598
|
"menu-go": ["place"],
|
|
1599
|
+
"menu-mentor": ["npc"],
|
|
1600
|
+
"cost-slot": [],
|
|
1601
|
+
"note-hint": ["npc"],
|
|
1602
|
+
"mentor-nothing": ["npc"],
|
|
1514
1603
|
"menu-sleep": [],
|
|
1515
1604
|
"menu-quit": [],
|
|
1516
1605
|
"keys-explore": ["keys"],
|
|
1517
1606
|
"keys-pick": ["keys"],
|
|
1518
1607
|
"keys-tiles": ["keys"],
|
|
1519
1608
|
"keys-help": ["keys"],
|
|
1609
|
+
"keys-notebook": [],
|
|
1610
|
+
"notebook-progress": ["stage", "known", "total", "heard"],
|
|
1611
|
+
"notebook-empty": [],
|
|
1612
|
+
"notebook-elsewhere": [],
|
|
1613
|
+
"notebook-notes": [],
|
|
1520
1614
|
"keys-help-sentence": ["keys"],
|
|
1521
1615
|
"help-sentence": [],
|
|
1522
1616
|
"help-in-replies": [],
|
|
1523
1617
|
"help-title": [],
|
|
1524
1618
|
"reply-title": [],
|
|
1619
|
+
"tiles-title": [],
|
|
1525
1620
|
"tiles-answer": [],
|
|
1526
1621
|
you: [],
|
|
1527
1622
|
mismatch: [],
|
|
@@ -1539,6 +1634,14 @@ var UI_KEYS = {
|
|
|
1539
1634
|
"resume-item": ["day", "place", "currency", "wallet", "done", "date"],
|
|
1540
1635
|
"resume-ask": [],
|
|
1541
1636
|
"resume-none": [],
|
|
1637
|
+
"export-none": [],
|
|
1638
|
+
"import-bad": ["reason"],
|
|
1639
|
+
"import-done": ["game"],
|
|
1640
|
+
...Object.fromEntries(
|
|
1641
|
+
["new", "games", "games-none", "export", "export-hint", "copy", "copied", "import", "import-hint", "import-go", "close", "saved"].map(
|
|
1642
|
+
(k) => [`web-${k}`, []]
|
|
1643
|
+
)
|
|
1644
|
+
),
|
|
1542
1645
|
...Object.fromEntries(REJECT_REASONS.map((c) => [`reject-${c}`, []]))
|
|
1543
1646
|
};
|
|
1544
1647
|
|
|
@@ -1584,10 +1687,11 @@ function renderScreen(m, cols, rows) {
|
|
|
1584
1687
|
const bodyRows = Math.max(1, rows - 2);
|
|
1585
1688
|
const wrap = (lines) => lines.flatMap((l) => wrapLine(l, inner));
|
|
1586
1689
|
const prompt = wrap(m.prompt).slice(-bodyRows);
|
|
1587
|
-
const
|
|
1690
|
+
const gap = prompt.length && bodyRows - prompt.length >= 2 ? 1 : 0;
|
|
1691
|
+
const logRows = Math.max(0, bodyRows - prompt.length - gap);
|
|
1588
1692
|
const log = logRows > 0 ? wrap(m.log).slice(-logRows) : [];
|
|
1589
1693
|
const body = [...Array(logRows - log.length).fill([]), ...log];
|
|
1590
|
-
if (
|
|
1694
|
+
if (gap) body.push([]);
|
|
1591
1695
|
body.push(...prompt);
|
|
1592
1696
|
return [
|
|
1593
1697
|
border("\u250C", m.title, "\u2510", "\u2500", cols, m.hud),
|
|
@@ -1596,8 +1700,54 @@ function renderScreen(m, cols, rows) {
|
|
|
1596
1700
|
];
|
|
1597
1701
|
}
|
|
1598
1702
|
|
|
1703
|
+
// ../tui/src/notebook.ts
|
|
1704
|
+
var MARK = { unseen: " ", met: "\u25CB", shaky: "\u25D0", known: "\u25CF" };
|
|
1705
|
+
function notebookLines(course2, state2, t, now) {
|
|
1706
|
+
const out = [];
|
|
1707
|
+
const stages = [...new Set(course2.scenes.map((s) => String(s.stage)))].sort();
|
|
1708
|
+
for (const stage of stages) {
|
|
1709
|
+
const list = course2.stageWords[stage] ?? [];
|
|
1710
|
+
const states = list.map((w) => wordState(state2.words[w], now));
|
|
1711
|
+
out.push([
|
|
1712
|
+
{
|
|
1713
|
+
text: t("notebook-progress", {
|
|
1714
|
+
stage,
|
|
1715
|
+
known: states.filter((s) => s === "known").length,
|
|
1716
|
+
total: list.length,
|
|
1717
|
+
heard: states.filter((s) => s !== "unseen").length
|
|
1718
|
+
}),
|
|
1719
|
+
bold: true
|
|
1720
|
+
}
|
|
1721
|
+
]);
|
|
1722
|
+
}
|
|
1723
|
+
const heard = Object.keys(state2.words).filter((w) => course2.words[w]);
|
|
1724
|
+
if (!heard.length) out.push([], [{ text: t("notebook-empty"), dim: true }]);
|
|
1725
|
+
const places = [...Object.keys(course2.world.places), ""];
|
|
1726
|
+
for (const place of places) {
|
|
1727
|
+
const words = heard.filter((w) => (state2.words[w].first?.place ?? "") === place);
|
|
1728
|
+
if (!words.length) continue;
|
|
1729
|
+
out.push([], [{ text: place ? t(`place-${place}`) : t("notebook-elsewhere"), color: "cyan", bold: true }]);
|
|
1730
|
+
for (const id of words) {
|
|
1731
|
+
const w = course2.words[id];
|
|
1732
|
+
const rec = state2.words[id];
|
|
1733
|
+
out.push([
|
|
1734
|
+
{ text: `${MARK[wordState(rec, now)]} ` },
|
|
1735
|
+
{ text: w.w, bold: true },
|
|
1736
|
+
...w.pron ? [{ text: ` ${w.pron}`, color: "yellow" }] : [],
|
|
1737
|
+
{ text: ` \u2014 ${w.gloss}` }
|
|
1738
|
+
]);
|
|
1739
|
+
if (rec.first) out.push([{ text: ` ${rec.first.line}`, dim: true }]);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
if (state2.notes.read.length) {
|
|
1743
|
+
out.push([], [{ text: t("notebook-notes"), color: "cyan", bold: true }]);
|
|
1744
|
+
for (const id of state2.notes.read) out.push([{ text: t(`note-${id}-title`), bold: true }], [{ text: t(`note-${id}`) }], []);
|
|
1745
|
+
}
|
|
1746
|
+
return out;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1599
1749
|
// ../tui/src/app.ts
|
|
1600
|
-
var
|
|
1750
|
+
var LOG_LIMIT2 = 200;
|
|
1601
1751
|
function startApp(opts) {
|
|
1602
1752
|
const { course: course2, core: core2, term: term2 } = opts;
|
|
1603
1753
|
const t = makeText(course2.learnerFtl);
|
|
@@ -1609,9 +1759,11 @@ function startApp(opts) {
|
|
|
1609
1759
|
let replyMode = "pick";
|
|
1610
1760
|
let tileInput = [];
|
|
1611
1761
|
let lastLine = null;
|
|
1762
|
+
let notebookFrom = "explore";
|
|
1763
|
+
let notebookTop = 0;
|
|
1612
1764
|
let resuming = false;
|
|
1613
1765
|
const push = (...lines) => {
|
|
1614
|
-
log = [...log, ...lines].slice(-
|
|
1766
|
+
log = [...log, ...lines].slice(-LOG_LIMIT2);
|
|
1615
1767
|
};
|
|
1616
1768
|
const npcName = (npc) => t(`npc-${npc}`);
|
|
1617
1769
|
const say = (npc, line, fresh, suffix = "") => [
|
|
@@ -1630,6 +1782,13 @@ function startApp(opts) {
|
|
|
1630
1782
|
const args = { currency: course2.world.currency, wallet: s.wallet, rent: course2.world.rentPerWeek };
|
|
1631
1783
|
for (let i = 1; t.has(`intro-${i}`); i++) push([{ text: t(`intro-${i}`, args) }], []);
|
|
1632
1784
|
}
|
|
1785
|
+
const actionArgs = (a) => Object.fromEntries(Object.entries(a).map(([k, v]) => [k, course2.conceptNames[v] ?? v]));
|
|
1786
|
+
function narrateAction(action, expected, matched, tilesWrong) {
|
|
1787
|
+
if (!tilesWrong && t.has(`action-${action.action}`)) push([{ text: t(`action-${action.action}`, actionArgs(action)), dim: true }]);
|
|
1788
|
+
if (matched) return;
|
|
1789
|
+
if (t.has(`asked-${expected.action}`)) push([{ text: t(`asked-${expected.action}`, actionArgs(expected)), color: "yellow" }]);
|
|
1790
|
+
else push([{ text: t("mismatch"), color: "yellow" }]);
|
|
1791
|
+
}
|
|
1633
1792
|
function enterPlace(place) {
|
|
1634
1793
|
push([], [{ text: t(`place-${place}`), bold: true }], [{ text: t(`place-${place}-desc`), dim: true }]);
|
|
1635
1794
|
}
|
|
@@ -1637,6 +1796,7 @@ function startApp(opts) {
|
|
|
1637
1796
|
const fresh = new Set(
|
|
1638
1797
|
events.flatMap((e) => e.type === "wordStateChanged" && e.from === "unseen" ? [e.word] : [])
|
|
1639
1798
|
);
|
|
1799
|
+
let hinted = false;
|
|
1640
1800
|
for (const e of events) {
|
|
1641
1801
|
switch (e.type) {
|
|
1642
1802
|
case "placeEntered":
|
|
@@ -1658,7 +1818,7 @@ function startApp(opts) {
|
|
|
1658
1818
|
else tiles = e.tiles;
|
|
1659
1819
|
break;
|
|
1660
1820
|
case "actionPerformed":
|
|
1661
|
-
|
|
1821
|
+
narrateAction(e.action, e.expected, e.matched, e.tilesWrong);
|
|
1662
1822
|
break;
|
|
1663
1823
|
case "npcReacted":
|
|
1664
1824
|
push(say(e.npc, e.line, fresh));
|
|
@@ -1700,6 +1860,15 @@ function startApp(opts) {
|
|
|
1700
1860
|
case "inputRejected":
|
|
1701
1861
|
push([{ text: t(`reject-${e.reason}`), color: "red" }]);
|
|
1702
1862
|
break;
|
|
1863
|
+
case "noteReady":
|
|
1864
|
+
if (course2.world.mentor && !hinted) push([{ text: t("note-hint", { npc: npcName(course2.world.mentor.npc) }), color: "magenta" }]);
|
|
1865
|
+
hinted = true;
|
|
1866
|
+
break;
|
|
1867
|
+
case "mentorVisited":
|
|
1868
|
+
push([]);
|
|
1869
|
+
if (!e.notes.length) push([{ text: t("mentor-nothing", { npc: npcName(e.npc) }), dim: true }]);
|
|
1870
|
+
for (const id of e.notes) push([{ text: t(`note-${id}-title`), bold: true }], [{ text: t(`note-${id}`) }], []);
|
|
1871
|
+
break;
|
|
1703
1872
|
case "wordStateChanged":
|
|
1704
1873
|
break;
|
|
1705
1874
|
}
|
|
@@ -1723,7 +1892,13 @@ function startApp(opts) {
|
|
|
1723
1892
|
for (const id of availableSceneIds(course2, s)) {
|
|
1724
1893
|
const scene = course2.scenes.find((x) => x.id === id);
|
|
1725
1894
|
if (scene.place !== s.place) continue;
|
|
1726
|
-
items.push({
|
|
1895
|
+
items.push({
|
|
1896
|
+
label: t("menu-talk", { npc: npcName(scene.npc), scene: t(`scene-${id}`) }) + t("cost-slot"),
|
|
1897
|
+
input: { type: "startScene", scene: id }
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
if (mentorAvailable(course2, s) && s.notes.ready.length) {
|
|
1901
|
+
items.push({ label: t("menu-mentor", { npc: npcName(course2.world.mentor.npc) }) + t("cost-slot"), input: { type: "visitMentor" } });
|
|
1727
1902
|
}
|
|
1728
1903
|
for (const p of course2.world.places[s.place].links) {
|
|
1729
1904
|
items.push({ label: t("menu-go", { place: t(`place-${p}`) }), input: { type: "goTo", place: p } });
|
|
@@ -1758,7 +1933,7 @@ function startApp(opts) {
|
|
|
1758
1933
|
const title = [{ text: t("reply-title"), dim: true }];
|
|
1759
1934
|
if (replyMode === "pick") return [title, ...pickOptions2.map((o, i) => [{ text: `${i + 1}) ` }, ...lineSpans(o, /* @__PURE__ */ new Set())])];
|
|
1760
1935
|
return [
|
|
1761
|
-
title,
|
|
1936
|
+
[{ text: t("tiles-title"), dim: true }],
|
|
1762
1937
|
...wrapItems(
|
|
1763
1938
|
tiles.map((x, i) => `[${i + 1}]${x}`),
|
|
1764
1939
|
width,
|
|
@@ -1776,14 +1951,36 @@ function startApp(opts) {
|
|
|
1776
1951
|
slots: course2.world.slotsPerDay,
|
|
1777
1952
|
currency: course2.world.currency,
|
|
1778
1953
|
wallet: s.wallet,
|
|
1779
|
-
rank: t(`rank-${rankFor(s.words, wordIds, opts.now())}`)
|
|
1954
|
+
rank: t(`rank-${rankFor(s.words, wordIds, opts.now())}`),
|
|
1955
|
+
rentLate: s.rentLate ? "yes" : "no"
|
|
1780
1956
|
});
|
|
1957
|
+
if (mode === "notebook") {
|
|
1958
|
+
const lines = notebookLines(course2, s, t, opts.now()).flatMap((l) => wrapLine(l, cols - 4));
|
|
1959
|
+
const bodyRows = Math.max(1, rows - 2);
|
|
1960
|
+
notebookTop = Math.max(0, Math.min(notebookTop, lines.length - bodyRows));
|
|
1961
|
+
const page = lines.slice(notebookTop, notebookTop + bodyRows);
|
|
1962
|
+
const prompt2 = [...page, ...Array(bodyRows - page.length).fill([])];
|
|
1963
|
+
term2.write(renderScreen({ title: t(`place-${s.place}`), hud, log: [], prompt: prompt2, footer: t("keys-notebook") }, cols, rows));
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1781
1966
|
const [footerId, count] = mode === "explore" ? ["keys-explore", menu().length] : mode === "help" ? [lastLine?.meaning ? "keys-help-sentence" : "keys-help", helpWords().length] : replyMode === "pick" ? ["keys-pick", pickOptions2.length] : ["keys-tiles", tiles.length];
|
|
1782
1967
|
const footer = t(footerId, { keys: keyRange(count) });
|
|
1783
1968
|
term2.write(renderScreen({ title: t(`place-${s.place}`), hud, log, prompt: prompt(cols - 4), footer }, cols, rows));
|
|
1784
1969
|
}
|
|
1785
1970
|
function press(key) {
|
|
1786
1971
|
if (key.name === "ctrl-c") return opts.quit();
|
|
1972
|
+
if (mode === "notebook") {
|
|
1973
|
+
if (key.name === "escape" || key.name === "n") mode = notebookFrom;
|
|
1974
|
+
else if (key.name === "down") notebookTop += 1;
|
|
1975
|
+
else if (key.name === "up") notebookTop = Math.max(0, notebookTop - 1);
|
|
1976
|
+
return render();
|
|
1977
|
+
}
|
|
1978
|
+
if (key.name === "n" && mode !== "help") {
|
|
1979
|
+
notebookFrom = mode;
|
|
1980
|
+
notebookTop = 0;
|
|
1981
|
+
mode = "notebook";
|
|
1982
|
+
return render();
|
|
1983
|
+
}
|
|
1787
1984
|
const n = /^[1-9]$/.test(key.name) ? Number(key.name) - 1 : -1;
|
|
1788
1985
|
if (mode === "explore") {
|
|
1789
1986
|
const item = n >= 0 ? menu()[n] : void 0;
|
|
@@ -1838,21 +2035,74 @@ function startApp(opts) {
|
|
|
1838
2035
|
return { press, render };
|
|
1839
2036
|
}
|
|
1840
2037
|
|
|
2038
|
+
// ../tui/src/save-line.ts
|
|
2039
|
+
var PREFIX = "st1:";
|
|
2040
|
+
async function transform(bytes, stream) {
|
|
2041
|
+
const out = new Blob([bytes]).stream().pipeThrough(stream);
|
|
2042
|
+
return new Uint8Array(await new Response(out).arrayBuffer());
|
|
2043
|
+
}
|
|
2044
|
+
function toBase64(bytes) {
|
|
2045
|
+
let bin = "";
|
|
2046
|
+
for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
2047
|
+
return btoa(bin);
|
|
2048
|
+
}
|
|
2049
|
+
async function encodeSave(state2) {
|
|
2050
|
+
const packed = await transform(new TextEncoder().encode(serialize(state2)), new CompressionStream("deflate-raw"));
|
|
2051
|
+
return PREFIX + toBase64(packed);
|
|
2052
|
+
}
|
|
2053
|
+
async function decodeSave(line, course2) {
|
|
2054
|
+
const text = line.replace(/\s+/g, "");
|
|
2055
|
+
const body = text.startsWith(PREFIX) ? text.slice(PREFIX.length) : "";
|
|
2056
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return { ok: false, reason: "not-a-save" };
|
|
2057
|
+
let json;
|
|
2058
|
+
try {
|
|
2059
|
+
const bytes = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
|
|
2060
|
+
json = new TextDecoder("utf-8", { fatal: true }).decode(await transform(bytes, new DecompressionStream("deflate-raw")));
|
|
2061
|
+
} catch {
|
|
2062
|
+
return { ok: false, reason: "not-a-save" };
|
|
2063
|
+
}
|
|
2064
|
+
return parseSave(json, course2);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// ../tui/src/session-lines.ts
|
|
2068
|
+
function sessionLines(sessions, course2, t, date) {
|
|
2069
|
+
return sessions.map((s, i) => {
|
|
2070
|
+
const text = t("resume-item", {
|
|
2071
|
+
day: s.state.day,
|
|
2072
|
+
place: t(`place-${s.state.place}`),
|
|
2073
|
+
currency: course2.world.currency,
|
|
2074
|
+
wallet: s.state.wallet,
|
|
2075
|
+
done: Object.values(s.state.scenesDone).filter((n) => n > 0).length,
|
|
2076
|
+
date: date(s.lastPlayed)
|
|
2077
|
+
});
|
|
2078
|
+
return `${i + 1}) ${text}`;
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1841
2082
|
// src/cli.ts
|
|
1842
2083
|
import { parseArgs } from "node:util";
|
|
1843
|
-
var USAGE = `Usage: silver-tongue [--new | --resume]
|
|
2084
|
+
var USAGE = `Usage: silver-tongue [--new | --resume | --export | --import <line>]
|
|
1844
2085
|
|
|
1845
|
-
(no flag)
|
|
1846
|
-
--new
|
|
1847
|
-
--resume
|
|
1848
|
-
--
|
|
2086
|
+
(no flag) continue the game you played last
|
|
2087
|
+
--new start a new game (your other games are kept)
|
|
2088
|
+
--resume choose one of your saved games
|
|
2089
|
+
--export print the game you played last as one line of text
|
|
2090
|
+
--import <line> add a game exported with --export (nothing is overwritten);
|
|
2091
|
+
use --import - to paste the line on standard input
|
|
2092
|
+
--help show this help`;
|
|
1849
2093
|
function parseFlags(args) {
|
|
1850
2094
|
let parsed;
|
|
1851
2095
|
try {
|
|
1852
2096
|
parsed = parseArgs({
|
|
1853
2097
|
args,
|
|
1854
2098
|
allowPositionals: true,
|
|
1855
|
-
options: {
|
|
2099
|
+
options: {
|
|
2100
|
+
new: { type: "boolean" },
|
|
2101
|
+
resume: { type: "boolean" },
|
|
2102
|
+
export: { type: "boolean" },
|
|
2103
|
+
import: { type: "string" },
|
|
2104
|
+
help: { type: "boolean", short: "h" }
|
|
2105
|
+
}
|
|
1856
2106
|
});
|
|
1857
2107
|
} catch (e) {
|
|
1858
2108
|
const unknown = /Unknown option '([^']+)'/.exec(e.message);
|
|
@@ -1860,22 +2110,12 @@ function parseFlags(args) {
|
|
|
1860
2110
|
}
|
|
1861
2111
|
const { values, positionals } = parsed;
|
|
1862
2112
|
if (values.help) return { mode: "help" };
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
return
|
|
1869
|
-
const text = t("resume-item", {
|
|
1870
|
-
day: s.state.day,
|
|
1871
|
-
place: t(`place-${s.state.place}`),
|
|
1872
|
-
currency: course2.world.currency,
|
|
1873
|
-
wallet: s.state.wallet,
|
|
1874
|
-
done: Object.values(s.state.scenesDone).filter((n) => n > 0).length,
|
|
1875
|
-
date: date(s.lastPlayed)
|
|
1876
|
-
});
|
|
1877
|
-
return `${i + 1}) ${text}`;
|
|
1878
|
-
});
|
|
2113
|
+
const chosen = ["new", "resume", "export", "import"].filter((k) => values[k] !== void 0 && values[k] !== false);
|
|
2114
|
+
if (chosen.length > 1) return { mode: "error", message: `use one of ${chosen.map((k) => `--${k}`).join(", ")}, not several` };
|
|
2115
|
+
const course2 = positionals[0] ? { coursePath: positionals[0] } : {};
|
|
2116
|
+
if (values.import !== void 0) return { mode: "import", line: values.import, ...course2 };
|
|
2117
|
+
const mode = chosen[0] === "new" || chosen[0] === "resume" || chosen[0] === "export" ? chosen[0] : "continue";
|
|
2118
|
+
return { mode, ...course2 };
|
|
1879
2119
|
}
|
|
1880
2120
|
function pickAnswer(answer, count) {
|
|
1881
2121
|
const a = answer.trim().toLowerCase();
|
|
@@ -1886,18 +2126,6 @@ function pickAnswer(answer, count) {
|
|
|
1886
2126
|
|
|
1887
2127
|
// src/node-terminal.ts
|
|
1888
2128
|
import readline from "node:readline";
|
|
1889
|
-
var COLORS = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36 };
|
|
1890
|
-
function toAnsi(line) {
|
|
1891
|
-
return line.map((s) => {
|
|
1892
|
-
const codes = [
|
|
1893
|
-
...s.bold ? [1] : [],
|
|
1894
|
-
...s.dim ? [2] : [],
|
|
1895
|
-
...s.underline ? [4] : [],
|
|
1896
|
-
...s.color ? [COLORS[s.color]] : []
|
|
1897
|
-
];
|
|
1898
|
-
return codes.length ? `\x1B[${codes.join(";")}m${s.text}\x1B[0m` : s.text;
|
|
1899
|
-
}).join("");
|
|
1900
|
-
}
|
|
1901
2129
|
function keyName(str, key) {
|
|
1902
2130
|
if (key?.ctrl && key.name === "c") return "ctrl-c";
|
|
1903
2131
|
return key?.name ?? str;
|
|
@@ -1985,12 +2213,20 @@ function configDir(env = process.env, platform = process.platform, home = homedi
|
|
|
1985
2213
|
if (platform === "win32") return env.APPDATA || join2(home, "AppData", "Roaming");
|
|
1986
2214
|
return env.XDG_CONFIG_HOME || join2(home, ".config");
|
|
1987
2215
|
}
|
|
2216
|
+
function backupPath(path) {
|
|
2217
|
+
const plainName = `${path}.invalid-backup`;
|
|
2218
|
+
if (!existsSync2(plainName)) return plainName;
|
|
2219
|
+
const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2220
|
+
let candidate = `${plainName}-${stamp2}`;
|
|
2221
|
+
for (let n = 2; existsSync2(candidate); n++) candidate = `${plainName}-${stamp2}-${n}`;
|
|
2222
|
+
return candidate;
|
|
2223
|
+
}
|
|
1988
2224
|
function loadSave(course2, path) {
|
|
1989
2225
|
try {
|
|
1990
2226
|
if (!existsSync2(path)) return { state: newGame(course2), readOnly: false };
|
|
1991
2227
|
const parsed = parseSave(readFileSync2(path, "utf8"), course2);
|
|
1992
2228
|
if (parsed.ok) return { state: parsed.state, readOnly: false };
|
|
1993
|
-
renameSync2(path,
|
|
2229
|
+
renameSync2(path, backupPath(path));
|
|
1994
2230
|
return { state: newGame(course2), notice: "notice-bad-save", readOnly: false };
|
|
1995
2231
|
} catch {
|
|
1996
2232
|
return { state: newGame(course2), notice: "notice-read-only", readOnly: true };
|
|
@@ -2046,6 +2282,39 @@ ${USAGE}`);
|
|
|
2046
2282
|
process.exit(2);
|
|
2047
2283
|
}
|
|
2048
2284
|
var course = JSON.parse(readFileSync3(coursePath(flags.coursePath), "utf8"));
|
|
2285
|
+
async function exportOrImport(mode, line) {
|
|
2286
|
+
const t = makeText(course.learnerFtl);
|
|
2287
|
+
const root = configDir();
|
|
2288
|
+
const dir = sessionsDir(root, course.id);
|
|
2289
|
+
try {
|
|
2290
|
+
migrateLegacySave(root, course.id);
|
|
2291
|
+
} catch {
|
|
2292
|
+
}
|
|
2293
|
+
if (mode === "export") {
|
|
2294
|
+
const last = listSessions(dir, course)[0];
|
|
2295
|
+
if (!last) {
|
|
2296
|
+
console.error(t("export-none"));
|
|
2297
|
+
process.exit(1);
|
|
2298
|
+
}
|
|
2299
|
+
console.log(await encodeSave(last.state));
|
|
2300
|
+
process.exit(0);
|
|
2301
|
+
}
|
|
2302
|
+
const decoded = await decodeSave(line === "-" ? readFileSync3(0, "utf8") : line, course);
|
|
2303
|
+
if (!decoded.ok) {
|
|
2304
|
+
console.error(t("import-bad", { reason: decoded.reason }));
|
|
2305
|
+
process.exit(1);
|
|
2306
|
+
}
|
|
2307
|
+
const now = Date.now();
|
|
2308
|
+
const path = newSessionPath(dir, now);
|
|
2309
|
+
if (!writeSave(path, decoded.state)) {
|
|
2310
|
+
console.error(t("notice-read-only"));
|
|
2311
|
+
process.exit(1);
|
|
2312
|
+
}
|
|
2313
|
+
const [summary] = sessionLines([{ lastPlayed: now, state: decoded.state }], course, t, (ms) => new Date(ms).toLocaleString());
|
|
2314
|
+
console.log(t("import-done", { game: summary.replace(/^1\) /, "") }));
|
|
2315
|
+
process.exit(0);
|
|
2316
|
+
}
|
|
2317
|
+
if (flags.mode === "export" || flags.mode === "import") await exportOrImport(flags.mode, flags.mode === "import" ? flags.line : "");
|
|
2049
2318
|
async function chooseSave() {
|
|
2050
2319
|
if (process.env.SILVER_TONGUE_SAVE) return process.env.SILVER_TONGUE_SAVE;
|
|
2051
2320
|
const root = configDir();
|
|
@@ -2055,7 +2324,7 @@ async function chooseSave() {
|
|
|
2055
2324
|
} catch {
|
|
2056
2325
|
}
|
|
2057
2326
|
const sessions = listSessions(dir, course);
|
|
2058
|
-
if (flags.mode
|
|
2327
|
+
if (flags.mode !== "new" && flags.mode !== "resume") return sessions[0]?.path ?? newSessionPath(dir, Date.now());
|
|
2059
2328
|
if (flags.mode === "new") return newSessionPath(dir, Date.now());
|
|
2060
2329
|
const t = makeText(course.learnerFtl);
|
|
2061
2330
|
if (!sessions.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "silver-tongue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Learn a language by living in it: a text game for the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/jamil314/silver-tongue.git", "directory": "packages/tui-node" },
|