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 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"}}]}
@@ -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
- for (const t of line.tokens) setWord(ctx, t.word, recordSeen);
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({ type: "actionPerformed", action: resolveParams(ex.expect, chosen), matched, diff, tilesWrong });
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
- for (const t of line.tokens) setWord(ctx, t.word, recordSeen);
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 logRows = Math.max(0, bodyRows - prompt.length - (prompt.length ? 1 : 0));
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 (prompt.length) body.push([]);
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 LOG_LIMIT = 200;
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(-LOG_LIMIT);
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
- if (!e.matched) push([{ text: t("mismatch"), color: "yellow" }]);
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({ label: t("menu-talk", { npc: npcName(scene.npc), scene: t(`scene-${id}`) }), input: { type: "startScene", scene: id } });
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
- var USAGE = `Usage: silver-tongue [--new | --resume]
2011
+ import { deflateRawSync, inflateRawSync } from "node:zlib";
2012
+ var USAGE = `Usage: silver-tongue [--new | --resume | --export | --import <line>]
1844
2013
 
1845
- (no flag) continue the game you played last
1846
- --new start a new game (your other games are kept)
1847
- --resume choose one of your saved games
1848
- --help show this help`;
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: { new: { type: "boolean" }, resume: { type: "boolean" }, help: { type: "boolean", short: "h" } }
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
- if (values.new && values.resume) return { mode: "error", message: "use --new or --resume, not both" };
1864
- const mode = values.new ? "new" : values.resume ? "resume" : "continue";
1865
- return positionals[0] ? { mode, coursePath: positionals[0] } : { mode };
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, `${path}.invalid-backup`);
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 === "continue") return sessions[0]?.path ?? newSessionPath(dir, Date.now());
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.1",
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" },