silver-tongue 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/courses/zh-china-en/course.json +1 -1
- package/dist/silver-tongue.mjs +263 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
|
@@ -12,6 +12,9 @@ The first course is Mandarin (HSK 1), in a Chinese city, explained in English.
|
|
|
12
12
|
npx silver-tongue # continue the game you played last
|
|
13
13
|
npx silver-tongue --new # start a new game (your other games are kept)
|
|
14
14
|
npx silver-tongue --resume # choose one of your saved games
|
|
15
|
+
npx silver-tongue --export # print the game you played last as one line of text
|
|
16
|
+
npx silver-tongue --import <line> # add a game exported elsewhere (nothing is overwritten)
|
|
17
|
+
npx silver-tongue --import - < game.txt # the same, reading the line from a file or a paste
|
|
15
18
|
```
|
|
16
19
|
|
|
17
20
|
| Key | Does |
|
|
@@ -19,6 +22,7 @@ npx silver-tongue --resume # choose one of your saved games
|
|
|
19
22
|
| `1`–`9` | choose from the menu, or pick a reply |
|
|
20
23
|
| `w` | word help: look up a word from the last line or the replies; `s` explains the whole sentence |
|
|
21
24
|
| `enter` / `⌫` | say / undo, when building a reply from tiles |
|
|
25
|
+
| `n` | notebook: every word you've heard, where you first heard it, and Old Wang's notes |
|
|
22
26
|
| `esc` | back |
|
|
23
27
|
| `q` | save and quit (from the menu) |
|
|
24
28
|
|
|
@@ -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 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.\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# 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
|
@@ -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);
|
|
@@ -1220,7 +1230,14 @@ function finishScene(ctx, scene) {
|
|
|
1220
1230
|
function resolve(ctx, scene, ex, chosen, diff, tilesWrong = false) {
|
|
1221
1231
|
const run = ctx.state.run;
|
|
1222
1232
|
const matched = diff.length === 0 && !tilesWrong;
|
|
1223
|
-
ctx.ev.push({
|
|
1233
|
+
ctx.ev.push({
|
|
1234
|
+
type: "actionPerformed",
|
|
1235
|
+
action: resolveParams(ex.expect, chosen),
|
|
1236
|
+
expected: resolveParams(ex.expect, run.combo),
|
|
1237
|
+
matched,
|
|
1238
|
+
diff,
|
|
1239
|
+
tilesWrong
|
|
1240
|
+
});
|
|
1224
1241
|
const hinges = hingeWords(ctx, ex, run.combo);
|
|
1225
1242
|
if (matched) {
|
|
1226
1243
|
for (const w of hinges) setWord(ctx, w, recordRight);
|
|
@@ -1239,7 +1256,7 @@ function resolve(ctx, scene, ex, chosen, diff, tilesWrong = false) {
|
|
|
1239
1256
|
const v = ex.variants[comboKey(run.combo)];
|
|
1240
1257
|
const line = v.rephrase ?? v.npc;
|
|
1241
1258
|
ctx.ev.push({ type: "lineRephrased", npc: scene.npc, line, slow: !v.rephrase });
|
|
1242
|
-
|
|
1259
|
+
hear(ctx, line);
|
|
1243
1260
|
}
|
|
1244
1261
|
emitOptions(ctx, ex);
|
|
1245
1262
|
}
|
|
@@ -1310,6 +1327,7 @@ var isStrings = (x) => Array.isArray(x) && x.every((s) => typeof s === "string")
|
|
|
1310
1327
|
var allValues = (o, ok) => Object.values(o).every(ok);
|
|
1311
1328
|
function isWordRecord(x) {
|
|
1312
1329
|
if (!isObj(x) || typeof x.lapsed !== "boolean") return false;
|
|
1330
|
+
if (x.first !== void 0 && !(isObj(x.first) && typeof x.first.line === "string" && typeof x.first.place === "string")) return false;
|
|
1313
1331
|
return ["right", "wrong", "streak", "helps", "firstSeen", "lastSeen"].every((k) => isCount(x[k]));
|
|
1314
1332
|
}
|
|
1315
1333
|
function isRunShape(x) {
|
|
@@ -1318,6 +1336,10 @@ function isRunShape(x) {
|
|
|
1318
1336
|
if (!["pick", "tiles", "type"].includes(x.mode) || !isStrings(x.options) || !isStrings(x.tiles)) return false;
|
|
1319
1337
|
return ["exchange", "misses", "earned", "mixups"].every((k) => isCount(x[k]));
|
|
1320
1338
|
}
|
|
1339
|
+
var INPUT_TYPES = /* @__PURE__ */ new Set(["goTo", "startScene", "reply", "replyTiles", "helpWord", "visitMentor", "sleep"]);
|
|
1340
|
+
function isLogEntry(x) {
|
|
1341
|
+
return isObj(x) && isCount(x.t) && isCount(x.day) && isCount(x.slot) && isObj(x.input) && INPUT_TYPES.has(x.input.type);
|
|
1342
|
+
}
|
|
1321
1343
|
function tilesCover(tiles, pieces2) {
|
|
1322
1344
|
const left = [...tiles];
|
|
1323
1345
|
return pieces2.every((p) => {
|
|
@@ -1352,12 +1374,45 @@ function parseSave(raw, course2) {
|
|
|
1352
1374
|
if (!isObj(data.scenesDone) || !allValues(data.scenesDone, isCount)) return { ok: false, reason: "bad-scenesDone" };
|
|
1353
1375
|
if (!isObj(data.words) || !allValues(data.words, isWordRecord)) return { ok: false, reason: "bad-words" };
|
|
1354
1376
|
if (data.run !== null && !isRunShape(data.run)) return { ok: false, reason: "bad-run" };
|
|
1377
|
+
if (!("notes" in data)) data.notes = { ready: [], read: [] };
|
|
1378
|
+
if (!("log" in data)) data.log = [];
|
|
1379
|
+
if (!isObj(data.notes) || !isStrings(data.notes.ready) || !isStrings(data.notes.read)) return { ok: false, reason: "bad-notes" };
|
|
1380
|
+
if (!Array.isArray(data.log) || !data.log.every(isLogEntry)) return { ok: false, reason: "bad-log" };
|
|
1381
|
+
const noteIds = new Set(course2.notes.map((n) => n.id));
|
|
1382
|
+
const keep = (ids) => [...new Set(ids)].filter((id) => noteIds.has(id));
|
|
1383
|
+
data.notes = { ready: keep(data.notes.ready), read: keep(data.notes.read) };
|
|
1355
1384
|
const state2 = data;
|
|
1356
1385
|
if (state2.run && !runFits(state2.run, course2)) state2.run = null;
|
|
1357
1386
|
return { ok: true, state: state2 };
|
|
1358
1387
|
}
|
|
1359
1388
|
|
|
1389
|
+
// ../core/src/mentor.ts
|
|
1390
|
+
function mentorAvailable(course2, state2) {
|
|
1391
|
+
const m = course2.world.mentor;
|
|
1392
|
+
if (!m) return false;
|
|
1393
|
+
return state2.place === course2.world.npcs[m.npc]?.place && (state2.scenesDone[m.after] ?? 0) > 0;
|
|
1394
|
+
}
|
|
1395
|
+
function triggered(note, state2) {
|
|
1396
|
+
return "word" in note.trigger ? !!state2.words[note.trigger.word]?.first : (state2.scenesDone[note.trigger.scene] ?? 0) > 0;
|
|
1397
|
+
}
|
|
1398
|
+
function newlyTriggered(course2, state2) {
|
|
1399
|
+
const known = /* @__PURE__ */ new Set([...state2.notes.ready, ...state2.notes.read]);
|
|
1400
|
+
return course2.notes.filter((n) => !known.has(n.id) && triggered(n, state2)).map((n) => n.id);
|
|
1401
|
+
}
|
|
1402
|
+
function visitMentor(ctx) {
|
|
1403
|
+
const { course: course2, state: state2 } = ctx;
|
|
1404
|
+
if (state2.run) return reject(ctx, "in-scene");
|
|
1405
|
+
if (!mentorAvailable(course2, state2)) return reject(ctx, "no-mentor");
|
|
1406
|
+
if (state2.slot >= course2.world.slotsPerDay) return reject(ctx, "no-slots");
|
|
1407
|
+
state2.slot += 1;
|
|
1408
|
+
const waiting = new Set(state2.notes.ready);
|
|
1409
|
+
const notes = course2.notes.filter((n) => waiting.has(n.id)).map((n) => n.id);
|
|
1410
|
+
state2.notes = { ready: [], read: [...state2.notes.read, ...notes] };
|
|
1411
|
+
ctx.ev.push({ type: "mentorVisited", npc: course2.world.mentor.npc, notes });
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1360
1414
|
// ../core/src/core.ts
|
|
1415
|
+
var LOG_LIMIT = 500;
|
|
1361
1416
|
function handle(ctx, input) {
|
|
1362
1417
|
const { course: course2, state: state2 } = ctx;
|
|
1363
1418
|
switch (input.type) {
|
|
@@ -1376,6 +1431,8 @@ function handle(ctx, input) {
|
|
|
1376
1431
|
case "helpWord":
|
|
1377
1432
|
if (!course2.words[input.word]) return reject(ctx, "unknown-word");
|
|
1378
1433
|
return setWord(ctx, input.word, recordHelp);
|
|
1434
|
+
case "visitMentor":
|
|
1435
|
+
return visitMentor(ctx);
|
|
1379
1436
|
case "sleep":
|
|
1380
1437
|
if (state2.run) return reject(ctx, "in-scene");
|
|
1381
1438
|
ctx.ev.push(...endDay(course2, state2));
|
|
@@ -1384,7 +1441,7 @@ function handle(ctx, input) {
|
|
|
1384
1441
|
}
|
|
1385
1442
|
function createCore(course2, initial, deps) {
|
|
1386
1443
|
const wordIds = Object.keys(course2.words);
|
|
1387
|
-
let state2 = initial;
|
|
1444
|
+
let state2 = { ...initial, notes: initial.notes ?? { ready: [], read: [] }, log: initial.log ?? [] };
|
|
1388
1445
|
return {
|
|
1389
1446
|
get state() {
|
|
1390
1447
|
return state2;
|
|
@@ -1394,10 +1451,15 @@ function createCore(course2, initial, deps) {
|
|
|
1394
1451
|
const ctx = { course: course2, state: structuredClone(state2), now, rng: deps.rng, ev: [] };
|
|
1395
1452
|
handle(ctx, input);
|
|
1396
1453
|
if (ctx.ev.some((e) => e.type === "inputRejected")) return ctx.ev;
|
|
1454
|
+
ctx.state.log = [...ctx.state.log, { t: now, day: state2.day, slot: state2.slot, input }].slice(-LOG_LIMIT);
|
|
1397
1455
|
const before = new Set(availableSceneIds(course2, state2));
|
|
1398
1456
|
for (const id of availableSceneIds(course2, ctx.state)) {
|
|
1399
1457
|
if (!before.has(id)) ctx.ev.push({ type: "unlocked", scene: id });
|
|
1400
1458
|
}
|
|
1459
|
+
for (const note of newlyTriggered(course2, ctx.state)) {
|
|
1460
|
+
ctx.state.notes.ready.push(note);
|
|
1461
|
+
ctx.ev.push({ type: "noteReady", note });
|
|
1462
|
+
}
|
|
1401
1463
|
const rank = rankFor(ctx.state.words, wordIds, now);
|
|
1402
1464
|
if (rank !== rankFor(state2.words, wordIds, now)) ctx.ev.push({ type: "rankChanged", rank });
|
|
1403
1465
|
state2 = ctx.state;
|
|
@@ -1502,7 +1564,7 @@ function makeText(ftl, locale = "en") {
|
|
|
1502
1564
|
return Object.assign(t, { has: (id) => !!bundle.getMessage(id)?.value });
|
|
1503
1565
|
}
|
|
1504
1566
|
var UI_KEYS = {
|
|
1505
|
-
hud: ["day", "slot", "slots", "currency", "wallet", "rank"],
|
|
1567
|
+
hud: ["day", "slot", "slots", "currency", "wallet", "rank", "rentLate"],
|
|
1506
1568
|
"rank-0": [],
|
|
1507
1569
|
"rank-1": [],
|
|
1508
1570
|
"rank-2": [],
|
|
@@ -1511,12 +1573,21 @@ var UI_KEYS = {
|
|
|
1511
1573
|
"menu-title": [],
|
|
1512
1574
|
"menu-talk": ["npc", "scene"],
|
|
1513
1575
|
"menu-go": ["place"],
|
|
1576
|
+
"menu-mentor": ["npc"],
|
|
1577
|
+
"cost-slot": [],
|
|
1578
|
+
"note-hint": ["npc"],
|
|
1579
|
+
"mentor-nothing": ["npc"],
|
|
1514
1580
|
"menu-sleep": [],
|
|
1515
1581
|
"menu-quit": [],
|
|
1516
1582
|
"keys-explore": ["keys"],
|
|
1517
1583
|
"keys-pick": ["keys"],
|
|
1518
1584
|
"keys-tiles": ["keys"],
|
|
1519
1585
|
"keys-help": ["keys"],
|
|
1586
|
+
"keys-notebook": [],
|
|
1587
|
+
"notebook-progress": ["stage", "known", "total", "heard"],
|
|
1588
|
+
"notebook-empty": [],
|
|
1589
|
+
"notebook-elsewhere": [],
|
|
1590
|
+
"notebook-notes": [],
|
|
1520
1591
|
"keys-help-sentence": ["keys"],
|
|
1521
1592
|
"help-sentence": [],
|
|
1522
1593
|
"help-in-replies": [],
|
|
@@ -1539,6 +1610,9 @@ var UI_KEYS = {
|
|
|
1539
1610
|
"resume-item": ["day", "place", "currency", "wallet", "done", "date"],
|
|
1540
1611
|
"resume-ask": [],
|
|
1541
1612
|
"resume-none": [],
|
|
1613
|
+
"export-none": [],
|
|
1614
|
+
"import-bad": ["reason"],
|
|
1615
|
+
"import-done": ["game"],
|
|
1542
1616
|
...Object.fromEntries(REJECT_REASONS.map((c) => [`reject-${c}`, []]))
|
|
1543
1617
|
};
|
|
1544
1618
|
|
|
@@ -1584,10 +1658,11 @@ function renderScreen(m, cols, rows) {
|
|
|
1584
1658
|
const bodyRows = Math.max(1, rows - 2);
|
|
1585
1659
|
const wrap = (lines) => lines.flatMap((l) => wrapLine(l, inner));
|
|
1586
1660
|
const prompt = wrap(m.prompt).slice(-bodyRows);
|
|
1587
|
-
const
|
|
1661
|
+
const gap = prompt.length && bodyRows - prompt.length >= 2 ? 1 : 0;
|
|
1662
|
+
const logRows = Math.max(0, bodyRows - prompt.length - gap);
|
|
1588
1663
|
const log = logRows > 0 ? wrap(m.log).slice(-logRows) : [];
|
|
1589
1664
|
const body = [...Array(logRows - log.length).fill([]), ...log];
|
|
1590
|
-
if (
|
|
1665
|
+
if (gap) body.push([]);
|
|
1591
1666
|
body.push(...prompt);
|
|
1592
1667
|
return [
|
|
1593
1668
|
border("\u250C", m.title, "\u2510", "\u2500", cols, m.hud),
|
|
@@ -1596,8 +1671,54 @@ function renderScreen(m, cols, rows) {
|
|
|
1596
1671
|
];
|
|
1597
1672
|
}
|
|
1598
1673
|
|
|
1674
|
+
// ../tui/src/notebook.ts
|
|
1675
|
+
var MARK = { unseen: " ", met: "\u25CB", shaky: "\u25D0", known: "\u25CF" };
|
|
1676
|
+
function notebookLines(course2, state2, t, now) {
|
|
1677
|
+
const out = [];
|
|
1678
|
+
const stages = [...new Set(course2.scenes.map((s) => String(s.stage)))].sort();
|
|
1679
|
+
for (const stage of stages) {
|
|
1680
|
+
const list = course2.stageWords[stage] ?? [];
|
|
1681
|
+
const states = list.map((w) => wordState(state2.words[w], now));
|
|
1682
|
+
out.push([
|
|
1683
|
+
{
|
|
1684
|
+
text: t("notebook-progress", {
|
|
1685
|
+
stage,
|
|
1686
|
+
known: states.filter((s) => s === "known").length,
|
|
1687
|
+
total: list.length,
|
|
1688
|
+
heard: states.filter((s) => s !== "unseen").length
|
|
1689
|
+
}),
|
|
1690
|
+
bold: true
|
|
1691
|
+
}
|
|
1692
|
+
]);
|
|
1693
|
+
}
|
|
1694
|
+
const heard = Object.keys(state2.words).filter((w) => course2.words[w]);
|
|
1695
|
+
if (!heard.length) out.push([], [{ text: t("notebook-empty"), dim: true }]);
|
|
1696
|
+
const places = [...Object.keys(course2.world.places), ""];
|
|
1697
|
+
for (const place of places) {
|
|
1698
|
+
const words = heard.filter((w) => (state2.words[w].first?.place ?? "") === place);
|
|
1699
|
+
if (!words.length) continue;
|
|
1700
|
+
out.push([], [{ text: place ? t(`place-${place}`) : t("notebook-elsewhere"), color: "cyan", bold: true }]);
|
|
1701
|
+
for (const id of words) {
|
|
1702
|
+
const w = course2.words[id];
|
|
1703
|
+
const rec = state2.words[id];
|
|
1704
|
+
out.push([
|
|
1705
|
+
{ text: `${MARK[wordState(rec, now)]} ` },
|
|
1706
|
+
{ text: w.w, bold: true },
|
|
1707
|
+
...w.pron ? [{ text: ` ${w.pron}`, color: "yellow" }] : [],
|
|
1708
|
+
{ text: ` \u2014 ${w.gloss}` }
|
|
1709
|
+
]);
|
|
1710
|
+
if (rec.first) out.push([{ text: ` ${rec.first.line}`, dim: true }]);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (state2.notes.read.length) {
|
|
1714
|
+
out.push([], [{ text: t("notebook-notes"), color: "cyan", bold: true }]);
|
|
1715
|
+
for (const id of state2.notes.read) out.push([{ text: t(`note-${id}-title`), bold: true }], [{ text: t(`note-${id}`) }], []);
|
|
1716
|
+
}
|
|
1717
|
+
return out;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1599
1720
|
// ../tui/src/app.ts
|
|
1600
|
-
var
|
|
1721
|
+
var LOG_LIMIT2 = 200;
|
|
1601
1722
|
function startApp(opts) {
|
|
1602
1723
|
const { course: course2, core: core2, term: term2 } = opts;
|
|
1603
1724
|
const t = makeText(course2.learnerFtl);
|
|
@@ -1609,9 +1730,11 @@ function startApp(opts) {
|
|
|
1609
1730
|
let replyMode = "pick";
|
|
1610
1731
|
let tileInput = [];
|
|
1611
1732
|
let lastLine = null;
|
|
1733
|
+
let notebookFrom = "explore";
|
|
1734
|
+
let notebookTop = 0;
|
|
1612
1735
|
let resuming = false;
|
|
1613
1736
|
const push = (...lines) => {
|
|
1614
|
-
log = [...log, ...lines].slice(-
|
|
1737
|
+
log = [...log, ...lines].slice(-LOG_LIMIT2);
|
|
1615
1738
|
};
|
|
1616
1739
|
const npcName = (npc) => t(`npc-${npc}`);
|
|
1617
1740
|
const say = (npc, line, fresh, suffix = "") => [
|
|
@@ -1630,6 +1753,13 @@ function startApp(opts) {
|
|
|
1630
1753
|
const args = { currency: course2.world.currency, wallet: s.wallet, rent: course2.world.rentPerWeek };
|
|
1631
1754
|
for (let i = 1; t.has(`intro-${i}`); i++) push([{ text: t(`intro-${i}`, args) }], []);
|
|
1632
1755
|
}
|
|
1756
|
+
const actionArgs = (a) => Object.fromEntries(Object.entries(a).map(([k, v]) => [k, course2.conceptNames[v] ?? v]));
|
|
1757
|
+
function narrateAction(action, expected, matched, tilesWrong) {
|
|
1758
|
+
if (!tilesWrong && t.has(`action-${action.action}`)) push([{ text: t(`action-${action.action}`, actionArgs(action)), dim: true }]);
|
|
1759
|
+
if (matched) return;
|
|
1760
|
+
if (t.has(`asked-${expected.action}`)) push([{ text: t(`asked-${expected.action}`, actionArgs(expected)), color: "yellow" }]);
|
|
1761
|
+
else push([{ text: t("mismatch"), color: "yellow" }]);
|
|
1762
|
+
}
|
|
1633
1763
|
function enterPlace(place) {
|
|
1634
1764
|
push([], [{ text: t(`place-${place}`), bold: true }], [{ text: t(`place-${place}-desc`), dim: true }]);
|
|
1635
1765
|
}
|
|
@@ -1637,6 +1767,7 @@ function startApp(opts) {
|
|
|
1637
1767
|
const fresh = new Set(
|
|
1638
1768
|
events.flatMap((e) => e.type === "wordStateChanged" && e.from === "unseen" ? [e.word] : [])
|
|
1639
1769
|
);
|
|
1770
|
+
let hinted = false;
|
|
1640
1771
|
for (const e of events) {
|
|
1641
1772
|
switch (e.type) {
|
|
1642
1773
|
case "placeEntered":
|
|
@@ -1658,7 +1789,7 @@ function startApp(opts) {
|
|
|
1658
1789
|
else tiles = e.tiles;
|
|
1659
1790
|
break;
|
|
1660
1791
|
case "actionPerformed":
|
|
1661
|
-
|
|
1792
|
+
narrateAction(e.action, e.expected, e.matched, e.tilesWrong);
|
|
1662
1793
|
break;
|
|
1663
1794
|
case "npcReacted":
|
|
1664
1795
|
push(say(e.npc, e.line, fresh));
|
|
@@ -1700,6 +1831,15 @@ function startApp(opts) {
|
|
|
1700
1831
|
case "inputRejected":
|
|
1701
1832
|
push([{ text: t(`reject-${e.reason}`), color: "red" }]);
|
|
1702
1833
|
break;
|
|
1834
|
+
case "noteReady":
|
|
1835
|
+
if (course2.world.mentor && !hinted) push([{ text: t("note-hint", { npc: npcName(course2.world.mentor.npc) }), color: "magenta" }]);
|
|
1836
|
+
hinted = true;
|
|
1837
|
+
break;
|
|
1838
|
+
case "mentorVisited":
|
|
1839
|
+
push([]);
|
|
1840
|
+
if (!e.notes.length) push([{ text: t("mentor-nothing", { npc: npcName(e.npc) }), dim: true }]);
|
|
1841
|
+
for (const id of e.notes) push([{ text: t(`note-${id}-title`), bold: true }], [{ text: t(`note-${id}`) }], []);
|
|
1842
|
+
break;
|
|
1703
1843
|
case "wordStateChanged":
|
|
1704
1844
|
break;
|
|
1705
1845
|
}
|
|
@@ -1723,7 +1863,13 @@ function startApp(opts) {
|
|
|
1723
1863
|
for (const id of availableSceneIds(course2, s)) {
|
|
1724
1864
|
const scene = course2.scenes.find((x) => x.id === id);
|
|
1725
1865
|
if (scene.place !== s.place) continue;
|
|
1726
|
-
items.push({
|
|
1866
|
+
items.push({
|
|
1867
|
+
label: t("menu-talk", { npc: npcName(scene.npc), scene: t(`scene-${id}`) }) + t("cost-slot"),
|
|
1868
|
+
input: { type: "startScene", scene: id }
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
if (mentorAvailable(course2, s) && s.notes.ready.length) {
|
|
1872
|
+
items.push({ label: t("menu-mentor", { npc: npcName(course2.world.mentor.npc) }) + t("cost-slot"), input: { type: "visitMentor" } });
|
|
1727
1873
|
}
|
|
1728
1874
|
for (const p of course2.world.places[s.place].links) {
|
|
1729
1875
|
items.push({ label: t("menu-go", { place: t(`place-${p}`) }), input: { type: "goTo", place: p } });
|
|
@@ -1776,14 +1922,36 @@ function startApp(opts) {
|
|
|
1776
1922
|
slots: course2.world.slotsPerDay,
|
|
1777
1923
|
currency: course2.world.currency,
|
|
1778
1924
|
wallet: s.wallet,
|
|
1779
|
-
rank: t(`rank-${rankFor(s.words, wordIds, opts.now())}`)
|
|
1925
|
+
rank: t(`rank-${rankFor(s.words, wordIds, opts.now())}`),
|
|
1926
|
+
rentLate: s.rentLate ? "yes" : "no"
|
|
1780
1927
|
});
|
|
1928
|
+
if (mode === "notebook") {
|
|
1929
|
+
const lines = notebookLines(course2, s, t, opts.now()).flatMap((l) => wrapLine(l, cols - 4));
|
|
1930
|
+
const bodyRows = Math.max(1, rows - 2);
|
|
1931
|
+
notebookTop = Math.max(0, Math.min(notebookTop, lines.length - bodyRows));
|
|
1932
|
+
const page = lines.slice(notebookTop, notebookTop + bodyRows);
|
|
1933
|
+
const prompt2 = [...page, ...Array(bodyRows - page.length).fill([])];
|
|
1934
|
+
term2.write(renderScreen({ title: t(`place-${s.place}`), hud, log: [], prompt: prompt2, footer: t("keys-notebook") }, cols, rows));
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1781
1937
|
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
1938
|
const footer = t(footerId, { keys: keyRange(count) });
|
|
1783
1939
|
term2.write(renderScreen({ title: t(`place-${s.place}`), hud, log, prompt: prompt(cols - 4), footer }, cols, rows));
|
|
1784
1940
|
}
|
|
1785
1941
|
function press(key) {
|
|
1786
1942
|
if (key.name === "ctrl-c") return opts.quit();
|
|
1943
|
+
if (mode === "notebook") {
|
|
1944
|
+
if (key.name === "escape" || key.name === "n") mode = notebookFrom;
|
|
1945
|
+
else if (key.name === "down") notebookTop += 1;
|
|
1946
|
+
else if (key.name === "up") notebookTop = Math.max(0, notebookTop - 1);
|
|
1947
|
+
return render();
|
|
1948
|
+
}
|
|
1949
|
+
if (key.name === "n" && mode !== "help") {
|
|
1950
|
+
notebookFrom = mode;
|
|
1951
|
+
notebookTop = 0;
|
|
1952
|
+
mode = "notebook";
|
|
1953
|
+
return render();
|
|
1954
|
+
}
|
|
1787
1955
|
const n = /^[1-9]$/.test(key.name) ? Number(key.name) - 1 : -1;
|
|
1788
1956
|
if (mode === "explore") {
|
|
1789
1957
|
const item = n >= 0 ? menu()[n] : void 0;
|
|
@@ -1840,19 +2008,29 @@ function startApp(opts) {
|
|
|
1840
2008
|
|
|
1841
2009
|
// src/cli.ts
|
|
1842
2010
|
import { parseArgs } from "node:util";
|
|
1843
|
-
|
|
2011
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
2012
|
+
var USAGE = `Usage: silver-tongue [--new | --resume | --export | --import <line>]
|
|
1844
2013
|
|
|
1845
|
-
(no flag)
|
|
1846
|
-
--new
|
|
1847
|
-
--resume
|
|
1848
|
-
--
|
|
2014
|
+
(no flag) continue the game you played last
|
|
2015
|
+
--new start a new game (your other games are kept)
|
|
2016
|
+
--resume choose one of your saved games
|
|
2017
|
+
--export print the game you played last as one line of text
|
|
2018
|
+
--import <line> add a game exported with --export (nothing is overwritten);
|
|
2019
|
+
use --import - to paste the line on standard input
|
|
2020
|
+
--help show this help`;
|
|
1849
2021
|
function parseFlags(args) {
|
|
1850
2022
|
let parsed;
|
|
1851
2023
|
try {
|
|
1852
2024
|
parsed = parseArgs({
|
|
1853
2025
|
args,
|
|
1854
2026
|
allowPositionals: true,
|
|
1855
|
-
options: {
|
|
2027
|
+
options: {
|
|
2028
|
+
new: { type: "boolean" },
|
|
2029
|
+
resume: { type: "boolean" },
|
|
2030
|
+
export: { type: "boolean" },
|
|
2031
|
+
import: { type: "string" },
|
|
2032
|
+
help: { type: "boolean", short: "h" }
|
|
2033
|
+
}
|
|
1856
2034
|
});
|
|
1857
2035
|
} catch (e) {
|
|
1858
2036
|
const unknown = /Unknown option '([^']+)'/.exec(e.message);
|
|
@@ -1860,9 +2038,12 @@ function parseFlags(args) {
|
|
|
1860
2038
|
}
|
|
1861
2039
|
const { values, positionals } = parsed;
|
|
1862
2040
|
if (values.help) return { mode: "help" };
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
2041
|
+
const chosen = ["new", "resume", "export", "import"].filter((k) => values[k] !== void 0 && values[k] !== false);
|
|
2042
|
+
if (chosen.length > 1) return { mode: "error", message: `use one of ${chosen.map((k) => `--${k}`).join(", ")}, not several` };
|
|
2043
|
+
const course2 = positionals[0] ? { coursePath: positionals[0] } : {};
|
|
2044
|
+
if (values.import !== void 0) return { mode: "import", line: values.import, ...course2 };
|
|
2045
|
+
const mode = chosen[0] === "new" || chosen[0] === "resume" || chosen[0] === "export" ? chosen[0] : "continue";
|
|
2046
|
+
return { mode, ...course2 };
|
|
1866
2047
|
}
|
|
1867
2048
|
function sessionLines(sessions, course2, t, date) {
|
|
1868
2049
|
return sessions.map((s, i) => {
|
|
@@ -1883,6 +2064,22 @@ function pickAnswer(answer, count) {
|
|
|
1883
2064
|
const n = Number(a);
|
|
1884
2065
|
return Number.isInteger(n) && n >= 1 && n <= count ? n - 1 : "again";
|
|
1885
2066
|
}
|
|
2067
|
+
var PREFIX = "st1:";
|
|
2068
|
+
function encodeSave(state2) {
|
|
2069
|
+
return PREFIX + deflateRawSync(Buffer.from(serialize(state2), "utf8")).toString("base64");
|
|
2070
|
+
}
|
|
2071
|
+
function decodeSave(line, course2) {
|
|
2072
|
+
const text = line.replace(/\s+/g, "");
|
|
2073
|
+
const body = text.startsWith(PREFIX) ? text.slice(PREFIX.length) : "";
|
|
2074
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return { ok: false, reason: "not-a-save" };
|
|
2075
|
+
let json;
|
|
2076
|
+
try {
|
|
2077
|
+
json = inflateRawSync(Buffer.from(body, "base64")).toString("utf8");
|
|
2078
|
+
} catch {
|
|
2079
|
+
return { ok: false, reason: "not-a-save" };
|
|
2080
|
+
}
|
|
2081
|
+
return parseSave(json, course2);
|
|
2082
|
+
}
|
|
1886
2083
|
|
|
1887
2084
|
// src/node-terminal.ts
|
|
1888
2085
|
import readline from "node:readline";
|
|
@@ -1985,12 +2182,20 @@ function configDir(env = process.env, platform = process.platform, home = homedi
|
|
|
1985
2182
|
if (platform === "win32") return env.APPDATA || join2(home, "AppData", "Roaming");
|
|
1986
2183
|
return env.XDG_CONFIG_HOME || join2(home, ".config");
|
|
1987
2184
|
}
|
|
2185
|
+
function backupPath(path) {
|
|
2186
|
+
const plainName = `${path}.invalid-backup`;
|
|
2187
|
+
if (!existsSync2(plainName)) return plainName;
|
|
2188
|
+
const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2189
|
+
let candidate = `${plainName}-${stamp2}`;
|
|
2190
|
+
for (let n = 2; existsSync2(candidate); n++) candidate = `${plainName}-${stamp2}-${n}`;
|
|
2191
|
+
return candidate;
|
|
2192
|
+
}
|
|
1988
2193
|
function loadSave(course2, path) {
|
|
1989
2194
|
try {
|
|
1990
2195
|
if (!existsSync2(path)) return { state: newGame(course2), readOnly: false };
|
|
1991
2196
|
const parsed = parseSave(readFileSync2(path, "utf8"), course2);
|
|
1992
2197
|
if (parsed.ok) return { state: parsed.state, readOnly: false };
|
|
1993
|
-
renameSync2(path,
|
|
2198
|
+
renameSync2(path, backupPath(path));
|
|
1994
2199
|
return { state: newGame(course2), notice: "notice-bad-save", readOnly: false };
|
|
1995
2200
|
} catch {
|
|
1996
2201
|
return { state: newGame(course2), notice: "notice-read-only", readOnly: true };
|
|
@@ -2046,6 +2251,39 @@ ${USAGE}`);
|
|
|
2046
2251
|
process.exit(2);
|
|
2047
2252
|
}
|
|
2048
2253
|
var course = JSON.parse(readFileSync3(coursePath(flags.coursePath), "utf8"));
|
|
2254
|
+
function exportOrImport(mode, line) {
|
|
2255
|
+
const t = makeText(course.learnerFtl);
|
|
2256
|
+
const root = configDir();
|
|
2257
|
+
const dir = sessionsDir(root, course.id);
|
|
2258
|
+
try {
|
|
2259
|
+
migrateLegacySave(root, course.id);
|
|
2260
|
+
} catch {
|
|
2261
|
+
}
|
|
2262
|
+
if (mode === "export") {
|
|
2263
|
+
const last = listSessions(dir, course)[0];
|
|
2264
|
+
if (!last) {
|
|
2265
|
+
console.error(t("export-none"));
|
|
2266
|
+
process.exit(1);
|
|
2267
|
+
}
|
|
2268
|
+
console.log(encodeSave(last.state));
|
|
2269
|
+
process.exit(0);
|
|
2270
|
+
}
|
|
2271
|
+
const decoded = decodeSave(line === "-" ? readFileSync3(0, "utf8") : line, course);
|
|
2272
|
+
if (!decoded.ok) {
|
|
2273
|
+
console.error(t("import-bad", { reason: decoded.reason }));
|
|
2274
|
+
process.exit(1);
|
|
2275
|
+
}
|
|
2276
|
+
const now = Date.now();
|
|
2277
|
+
const path = newSessionPath(dir, now);
|
|
2278
|
+
if (!writeSave(path, decoded.state)) {
|
|
2279
|
+
console.error(t("notice-read-only"));
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
}
|
|
2282
|
+
const [summary] = sessionLines([{ id: "", path, lastPlayed: now, state: decoded.state }], course, t, (ms) => new Date(ms).toLocaleString());
|
|
2283
|
+
console.log(t("import-done", { game: summary.replace(/^1\) /, "") }));
|
|
2284
|
+
process.exit(0);
|
|
2285
|
+
}
|
|
2286
|
+
if (flags.mode === "export" || flags.mode === "import") exportOrImport(flags.mode, flags.mode === "import" ? flags.line : "");
|
|
2049
2287
|
async function chooseSave() {
|
|
2050
2288
|
if (process.env.SILVER_TONGUE_SAVE) return process.env.SILVER_TONGUE_SAVE;
|
|
2051
2289
|
const root = configDir();
|
|
@@ -2055,7 +2293,7 @@ async function chooseSave() {
|
|
|
2055
2293
|
} catch {
|
|
2056
2294
|
}
|
|
2057
2295
|
const sessions = listSessions(dir, course);
|
|
2058
|
-
if (flags.mode
|
|
2296
|
+
if (flags.mode !== "new" && flags.mode !== "resume") return sessions[0]?.path ?? newSessionPath(dir, Date.now());
|
|
2059
2297
|
if (flags.mode === "new") return newSessionPath(dir, Date.now());
|
|
2060
2298
|
const t = makeText(course.learnerFtl);
|
|
2061
2299
|
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.0",
|
|
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" },
|