silver-tongue 0.3.1 → 0.4.1

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