silver-tongue 0.1.1 → 0.3.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,25 +2,27 @@
2
2
 
3
3
  > You arrive speaking pidgin; you leave with a silver tongue.
4
4
 
5
- A language-learning life game. You arrive in a new city knowing a few words and 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; 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
9
  ## Play in the terminal
10
10
 
11
11
  ```sh
12
- npx silver-tongue
12
+ npx silver-tongue # continue the game you played last
13
+ npx silver-tongue --new # start a new game (your other games are kept)
14
+ npx silver-tongue --resume # choose one of your saved games
13
15
  ```
14
16
 
15
17
  | Key | Does |
16
18
  |---|---|
17
19
  | `1`–`9` | choose from the menu, or pick a reply |
18
- | `w` | word help: look up a word from the last line |
20
+ | `w` | word help: look up a word from the last line or the replies; `s` explains the whole sentence |
19
21
  | `enter` / `⌫` | say / undo, when building a reply from tiles |
20
22
  | `esc` | back |
21
23
  | `q` | save and quit (from the menu) |
22
24
 
23
- Progress saves automatically to `~/.config/silver-tongue/<course>.json` (or under `$XDG_CONFIG_HOME`, or `%APPDATA%` on Windows). If a save can't be read, it is kept next to it as `<course>.json.invalid-backup` and a new game starts. If saving fails, the game says so and plays on without saving.
25
+ Progress saves automatically. Each game is one file in `~/.config/silver-tongue/sessions/<course>/` (or under `$XDG_CONFIG_HOME`, or `%APPDATA%` on Windows); a save from 0.2.0 or earlier is moved there on first run. If a save can't be read, it is kept next to it as `<name>.json.invalid-backup` and a new game starts. If saving fails, the game says so and plays on without saving.
24
26
 
25
27
  Needs Node 22 or newer. Source, issues and other ways to play: https://github.com/jamil314/silver-tongue
26
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ù"},"w0020":{"id":"w0020","w":"五","lv":"1","gloss":"five; 5","pron":"wǔ"},"w0028":{"id":"w0028","w":"你","lv":"1","gloss":"you (informal)","pron":"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"},"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ǐ"},"w0133":{"id":"w0133","w":"茶","lv":"1","gloss":"tea; tea plant","pron":"chá"},"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"]},"groups":{"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":{"cook":{"place":"noodle_shop"}}},"scenes":[{"id":"noodle-intro","place":"noodle_shop","npc":"cook","stage":1,"after":[],"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"}]},"reply":{"text":"你好!","tokens":[{"start":0,"end":1,"word":"w0028"},{"start":1,"end":2,"word":"w0072"}]}}}},{"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"}]},"reply":{"text":"好。","tokens":[{"start":0,"end":1,"word":"w0072"}]}}}}]},{"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"}]},"reply":{"text":"好,茶。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0133"}]}},"item=water":{"npc":{"text":"水。","tokens":[{"start":0,"end":1,"word":"w0108"}]},"reply":{"text":"好,水。","tokens":[{"start":0,"end":1,"word":"w0072"},{"start":2,"end":3,"word":"w0108"}]}}}},{"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"}]},"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"}]},"rephrase":{"text":"茶。三杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"}]}},"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"}]},"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"}]},"rephrase":{"text":"水。三杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0003"},{"start":3,"end":4,"word":"x0001"}]}},"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"}]},"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"}]},"rephrase":{"text":"茶。四杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"}]}},"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"}]},"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"}]},"rephrase":{"text":"水。四杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0060"},{"start":3,"end":4,"word":"x0001"}]}},"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"}]},"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"}]},"rephrase":{"text":"茶。五杯。","tokens":[{"start":0,"end":1,"word":"w0133"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"}]}},"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"}]},"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"}]},"rephrase":{"text":"水。五杯。","tokens":[{"start":0,"end":1,"word":"w0108"},{"start":2,"end":3,"word":"w0020"},{"start":3,"end":4,"word":"x0001"}]}}}}]}],"reactions":{"wrong-generic":{"text":"不是。","tokens":[{"start":0,"end":1,"word":"w0009"},{"start":1,"end":2,"word":"w0099"}]},"wrong-count":{"text":"几杯?","tokens":[{"start":0,"end":1,"word":"w0037"},{"start":1,"end":2,"word":"x0001"}]}},"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\n\nhelp-title = Which word?\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 = Done. You earned { $currency }{ $earned }.\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\nplace-street = The street\nplace-street-desc = Bikes, steam and shouting. A noodle shop glows across the road.\nplace-noodle_shop = Noodle shop\nplace-noodle_shop-desc = Steam everywhere. The cook waves you over.\n\nnpc-cook = Cook\n\nscene-noodle-intro = Get a job\nscene-noodle-shift = Work a shift\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","bye"],"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":"Work here — OK?"},"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!"}},"said=bye":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}},{"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!"}},"said=bye":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}},{"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":"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!"}},"said=bye":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}},{"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!"}},"said=bye":{"npc":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"},"reply":{"text":"再见!","tokens":[{"start":0,"end":2,"word":"w0034"}],"meaning":"Goodbye!"}}}},{"id":"polite-3","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!"}},"said=bye":{"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 = Done. You earned { $currency }{ $earned }.\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 = The 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"}
@@ -928,8 +928,8 @@ var require_bundle = __commonJS({
928
928
  });
929
929
 
930
930
  // src/main.ts
931
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
932
- import { join as join2 } from "node:path";
931
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
932
+ import { createInterface } from "node:readline/promises";
933
933
  import { fileURLToPath } from "node:url";
934
934
 
935
935
  // ../core/src/types.ts
@@ -1167,14 +1167,14 @@ function pickOptions(ctx, ex, combo) {
1167
1167
  }
1168
1168
  function buildTiles(ctx, ex, combo) {
1169
1169
  const rightKey = comboKey(combo);
1170
- const pieces = tilePieces(ex.variants[rightKey].reply);
1170
+ const pieces2 = tilePieces(ex.variants[rightKey].reply);
1171
1171
  const extra = /* @__PURE__ */ new Set();
1172
1172
  for (const key of shuffle(Object.keys(ex.variants), ctx.rng)) {
1173
1173
  if (key === rightKey) continue;
1174
- for (const p of tilePieces(ex.variants[key].reply)) if (!pieces.includes(p)) extra.add(p);
1174
+ for (const p of tilePieces(ex.variants[key].reply)) if (!pieces2.includes(p)) extra.add(p);
1175
1175
  if (extra.size >= 2) break;
1176
1176
  }
1177
- return shuffle([...pieces, ...[...extra].slice(0, 2)], ctx.rng);
1177
+ return shuffle([...pieces2, ...[...extra].slice(0, 2)], ctx.rng);
1178
1178
  }
1179
1179
  function optionsEvent(ex, run) {
1180
1180
  return run.mode === "pick" ? { type: "replyOptions", mode: "pick", options: run.options.map((k) => ex.variants[k].reply) } : { type: "replyOptions", mode: "tiles", tiles: run.tiles };
@@ -1318,9 +1318,9 @@ function isRunShape(x) {
1318
1318
  if (!["pick", "tiles", "type"].includes(x.mode) || !isStrings(x.options) || !isStrings(x.tiles)) return false;
1319
1319
  return ["exchange", "misses", "earned", "mixups"].every((k) => isCount(x[k]));
1320
1320
  }
1321
- function tilesCover(tiles, pieces) {
1321
+ function tilesCover(tiles, pieces2) {
1322
1322
  const left = [...tiles];
1323
- return pieces.every((p) => {
1323
+ return pieces2.every((p) => {
1324
1324
  const i = left.indexOf(p);
1325
1325
  return i >= 0 && left.splice(i, 1).length === 1;
1326
1326
  });
@@ -1439,16 +1439,67 @@ function fitLine(line, cols) {
1439
1439
  if (used < cols) out.push({ text: " ".repeat(cols - used) });
1440
1440
  return out;
1441
1441
  }
1442
+ function pieces(line) {
1443
+ const out = [];
1444
+ for (const span of line) {
1445
+ let cur = "";
1446
+ const flush = () => {
1447
+ if (cur) out.push({ text: cur, span });
1448
+ cur = "";
1449
+ };
1450
+ for (const ch of span.text) {
1451
+ const wide = charWidth(ch.codePointAt(0)) === 2;
1452
+ if (ch === " ") cur += ch;
1453
+ else if (wide || cur.endsWith(" ")) {
1454
+ flush();
1455
+ cur = ch;
1456
+ if (wide) flush();
1457
+ } else cur += ch;
1458
+ }
1459
+ flush();
1460
+ }
1461
+ return out;
1462
+ }
1463
+ function wrapLine(line, cols) {
1464
+ const lines = [];
1465
+ let cur = [];
1466
+ let curFrom;
1467
+ let used = 0;
1468
+ const finish = () => {
1469
+ const last = cur.at(-1);
1470
+ if (last) last.text = last.text.replace(/ +$/, "");
1471
+ lines.push(cur.filter((s) => s.text));
1472
+ cur = [];
1473
+ curFrom = void 0;
1474
+ used = 0;
1475
+ };
1476
+ const add = ({ text, span }) => {
1477
+ if (used > 0 && used + strWidth(text.replace(/ +$/, "")) > cols) finish();
1478
+ if (used === 0 && lines.length > 0) text = text.replace(/^ +/, "");
1479
+ if (!text) return;
1480
+ if (curFrom === span) cur[cur.length - 1].text += text;
1481
+ else cur.push({ ...span, text });
1482
+ curFrom = span;
1483
+ used += strWidth(text);
1484
+ };
1485
+ for (const p of pieces(line)) {
1486
+ if (strWidth(p.text.replace(/ +$/, "")) > cols) for (const ch of p.text) add({ text: ch, span: p.span });
1487
+ else add(p);
1488
+ }
1489
+ finish();
1490
+ return lines;
1491
+ }
1442
1492
 
1443
1493
  // ../tui/src/text.ts
1444
1494
  var import_bundle = __toESM(require_bundle(), 1);
1445
1495
  function makeText(ftl, locale = "en") {
1446
1496
  const bundle = new import_bundle.FluentBundle(locale, { useIsolating: false });
1447
1497
  bundle.addResource(new import_bundle.FluentResource(ftl));
1448
- return (id, args) => {
1498
+ const t = (id, args) => {
1449
1499
  const msg = bundle.getMessage(id);
1450
1500
  return msg?.value ? bundle.formatPattern(msg.value, args ?? {}, []) : id;
1451
1501
  };
1502
+ return Object.assign(t, { has: (id) => !!bundle.getMessage(id)?.value });
1452
1503
  }
1453
1504
  var UI_KEYS = {
1454
1505
  hud: ["day", "slot", "slots", "currency", "wallet", "rank"],
@@ -1466,6 +1517,9 @@ var UI_KEYS = {
1466
1517
  "keys-pick": ["keys"],
1467
1518
  "keys-tiles": ["keys"],
1468
1519
  "keys-help": ["keys"],
1520
+ "keys-help-sentence": ["keys"],
1521
+ "help-sentence": [],
1522
+ "help-in-replies": [],
1469
1523
  "help-title": [],
1470
1524
  "reply-title": [],
1471
1525
  "tiles-answer": [],
@@ -1481,6 +1535,10 @@ var UI_KEYS = {
1481
1535
  "day-ended": ["day"],
1482
1536
  "notice-bad-save": [],
1483
1537
  "notice-read-only": [],
1538
+ "resume-title": [],
1539
+ "resume-item": ["day", "place", "currency", "wallet", "done", "date"],
1540
+ "resume-ask": [],
1541
+ "resume-none": [],
1484
1542
  ...Object.fromEntries(REJECT_REASONS.map((c) => [`reject-${c}`, []]))
1485
1543
  };
1486
1544
 
@@ -1524,9 +1582,10 @@ function border(left, label, right, fill, cols, rightLabel = "") {
1524
1582
  function renderScreen(m, cols, rows) {
1525
1583
  const inner = Math.max(1, cols - 4);
1526
1584
  const bodyRows = Math.max(1, rows - 2);
1527
- const prompt = m.prompt.slice(-bodyRows);
1585
+ const wrap = (lines) => lines.flatMap((l) => wrapLine(l, inner));
1586
+ const prompt = wrap(m.prompt).slice(-bodyRows);
1528
1587
  const logRows = Math.max(0, bodyRows - prompt.length - (prompt.length ? 1 : 0));
1529
- const log = logRows > 0 ? m.log.slice(-logRows) : [];
1588
+ const log = logRows > 0 ? wrap(m.log).slice(-logRows) : [];
1530
1589
  const body = [...Array(logRows - log.length).fill([]), ...log];
1531
1590
  if (prompt.length) body.push([]);
1532
1591
  body.push(...prompt);
@@ -1550,6 +1609,7 @@ function startApp(opts) {
1550
1609
  let replyMode = "pick";
1551
1610
  let tileInput = [];
1552
1611
  let lastLine = null;
1612
+ let resuming = false;
1553
1613
  const push = (...lines) => {
1554
1614
  log = [...log, ...lines].slice(-LOG_LIMIT);
1555
1615
  };
@@ -1560,6 +1620,16 @@ function startApp(opts) {
1560
1620
  ];
1561
1621
  const keyRange = (n) => n <= 1 ? "1" : `1-${Math.min(n, 9)}`;
1562
1622
  const echo = (text) => push([{ text: `${t("you")}: `, color: "green", bold: true }, { text }]);
1623
+ const narrate = (id, args) => {
1624
+ if (t.has(id)) push([{ text: t(id, args), dim: true }]);
1625
+ };
1626
+ function tellIntro() {
1627
+ const s = core2.state;
1628
+ const fresh = s.day === 1 && s.slot === 0 && !s.run && s.place === course2.world.start && !Object.keys(s.scenesDone).length && !Object.keys(s.words).length;
1629
+ if (!fresh) return;
1630
+ const args = { currency: course2.world.currency, wallet: s.wallet, rent: course2.world.rentPerWeek };
1631
+ for (let i = 1; t.has(`intro-${i}`); i++) push([{ text: t(`intro-${i}`, args) }], []);
1632
+ }
1563
1633
  function enterPlace(place) {
1564
1634
  push([], [{ text: t(`place-${place}`), bold: true }], [{ text: t(`place-${place}-desc`), dim: true }]);
1565
1635
  }
@@ -1575,6 +1645,7 @@ function startApp(opts) {
1575
1645
  case "sceneStarted":
1576
1646
  mode = "scene";
1577
1647
  push([]);
1648
+ if (!resuming) narrate(`scene-${e.scene}-start`);
1578
1649
  break;
1579
1650
  case "lineSpoken":
1580
1651
  lastLine = e.line;
@@ -1614,6 +1685,7 @@ function startApp(opts) {
1614
1685
  break;
1615
1686
  case "sceneEnded":
1616
1687
  mode = "explore";
1688
+ narrate(`scene-${e.scene}-end`);
1617
1689
  push([{ text: t("scene-done", { currency: course2.world.currency, earned: e.earned }), bold: true }]);
1618
1690
  break;
1619
1691
  case "unlocked":
@@ -1660,16 +1732,28 @@ function startApp(opts) {
1660
1732
  }
1661
1733
  function helpWords() {
1662
1734
  if (!lastLine) return [];
1663
- const line = lastLine;
1664
- return line.tokens.map((tk) => ({ text: line.text.slice(tk.start, tk.end), word: tk.word }));
1735
+ const pieces2 = (l) => l.tokens.map((tk) => ({ text: l.text.slice(tk.start, tk.end), word: tk.word }));
1736
+ const said = pieces2(lastLine).map((p) => ({ ...p, inReplies: false }));
1737
+ const seen = new Set(said.map((p) => p.word));
1738
+ const replies = replyMode === "pick" ? pickOptions2.flatMap(pieces2) : [];
1739
+ const extra = replies.filter((p) => !seen.has(p.word) && seen.add(p.word)).map((p) => ({ ...p, inReplies: true }));
1740
+ return [...said, ...extra].slice(0, 9);
1665
1741
  }
1666
1742
  function prompt(width) {
1667
1743
  if (mode === "explore") {
1668
1744
  return [[{ text: t("menu-title"), dim: true }], ...menu().map((m, i) => [{ text: `${i + 1}) ${m.label}` }])];
1669
1745
  }
1670
1746
  if (mode === "help") {
1671
- const words = helpWords().map((w, i) => `${i + 1}) ${w.text}`);
1672
- return [[{ text: t("help-title"), dim: true }], ...wrapItems(words, width)];
1747
+ const items = helpWords().map((w, i) => ({ ...w, label: `${i + 1}) ${w.text}` }));
1748
+ const said = items.filter((w) => !w.inReplies).map((w) => w.label);
1749
+ const inReplies = items.filter((w) => w.inReplies).map((w) => w.label);
1750
+ const sentence = lastLine?.meaning ? [[{ text: `s) ${t("help-sentence")}` }]] : [];
1751
+ return [
1752
+ [{ text: t("help-title"), dim: true }],
1753
+ ...wrapItems(said, width),
1754
+ ...sentence,
1755
+ ...inReplies.length ? [[{ text: t("help-in-replies"), dim: true }], ...wrapItems(inReplies, width)] : []
1756
+ ];
1673
1757
  }
1674
1758
  const title = [{ text: t("reply-title"), dim: true }];
1675
1759
  if (replyMode === "pick") return [title, ...pickOptions2.map((o, i) => [{ text: `${i + 1}) ` }, ...lineSpans(o, /* @__PURE__ */ new Set())])];
@@ -1694,7 +1778,7 @@ function startApp(opts) {
1694
1778
  wallet: s.wallet,
1695
1779
  rank: t(`rank-${rankFor(s.words, wordIds, opts.now())}`)
1696
1780
  });
1697
- const [footerId, count] = mode === "explore" ? ["keys-explore", menu().length] : mode === "help" ? ["keys-help", helpWords().length] : replyMode === "pick" ? ["keys-pick", pickOptions2.length] : ["keys-tiles", tiles.length];
1781
+ 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];
1698
1782
  const footer = t(footerId, { keys: keyRange(count) });
1699
1783
  term2.write(renderScreen({ title: t(`place-${s.place}`), hud, log, prompt: prompt(cols - 4), footer }, cols, rows));
1700
1784
  }
@@ -1716,6 +1800,14 @@ function startApp(opts) {
1716
1800
  { text: ` \u2014 ${w.gloss}` }
1717
1801
  ]);
1718
1802
  }
1803
+ if (key.name === "s" && lastLine?.meaning) {
1804
+ const pron = lastLine.tokens.flatMap((tk) => course2.words[tk.word]?.pron ?? []).join(" ");
1805
+ push([
1806
+ { text: lastLine.text, bold: true },
1807
+ ...pron ? [{ text: ` ${pron}`, color: "yellow" }] : [],
1808
+ { text: ` \u2014 ${lastLine.meaning}` }
1809
+ ]);
1810
+ }
1719
1811
  if (key.name === "escape" || key.name === "w") mode = "scene";
1720
1812
  } else if (key.name === "w") {
1721
1813
  mode = "help";
@@ -1735,14 +1827,63 @@ function startApp(opts) {
1735
1827
  render();
1736
1828
  }
1737
1829
  if (opts.notice) push([{ text: t(opts.notice), color: "yellow" }]);
1830
+ tellIntro();
1738
1831
  enterPlace(core2.state.place);
1832
+ resuming = true;
1739
1833
  apply(describeRun(course2, core2.state));
1834
+ resuming = false;
1740
1835
  term2.onKey(press);
1741
1836
  term2.onResize(render);
1742
1837
  render();
1743
1838
  return { press, render };
1744
1839
  }
1745
1840
 
1841
+ // src/cli.ts
1842
+ import { parseArgs } from "node:util";
1843
+ var USAGE = `Usage: silver-tongue [--new | --resume]
1844
+
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`;
1849
+ function parseFlags(args) {
1850
+ let parsed;
1851
+ try {
1852
+ parsed = parseArgs({
1853
+ args,
1854
+ allowPositionals: true,
1855
+ options: { new: { type: "boolean" }, resume: { type: "boolean" }, help: { type: "boolean", short: "h" } }
1856
+ });
1857
+ } catch (e) {
1858
+ const unknown = /Unknown option '([^']+)'/.exec(e.message);
1859
+ return { mode: "error", message: unknown ? `unknown option ${unknown[1]}` : e.message };
1860
+ }
1861
+ const { values, positionals } = parsed;
1862
+ 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
+ });
1879
+ }
1880
+ function pickAnswer(answer, count) {
1881
+ const a = answer.trim().toLowerCase();
1882
+ if (a === "" || a === "q") return "cancel";
1883
+ const n = Number(a);
1884
+ return Number.isInteger(n) && n >= 1 && n <= count ? n - 1 : "again";
1885
+ }
1886
+
1746
1887
  // src/node-terminal.ts
1747
1888
  import readline from "node:readline";
1748
1889
  var COLORS = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36 };
@@ -1764,6 +1905,7 @@ function keyName(str, key) {
1764
1905
  function createNodeTerminal(input = process.stdin, output = process.stdout) {
1765
1906
  readline.emitKeypressEvents(input);
1766
1907
  if (input.isTTY) input.setRawMode(true);
1908
+ input.resume();
1767
1909
  output.write("\x1B[?1049h\x1B[?25l");
1768
1910
  const handlers = [];
1769
1911
  input.on("keypress", (str, key) => {
@@ -1800,20 +1942,55 @@ function createNodeTerminal(input = process.stdin, output = process.stdout) {
1800
1942
  };
1801
1943
  }
1802
1944
 
1945
+ // src/sessions.ts
1946
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync } from "node:fs";
1947
+ import { join } from "node:path";
1948
+ function sessionsDir(configRoot, courseId) {
1949
+ return join(configRoot, "silver-tongue", "sessions", courseId);
1950
+ }
1951
+ function listSessions(dir, course2) {
1952
+ if (!existsSync(dir)) return [];
1953
+ const out = [];
1954
+ for (const file of readdirSync(dir)) {
1955
+ if (!file.endsWith(".json")) continue;
1956
+ const path = join(dir, file);
1957
+ try {
1958
+ const parsed = parseSave(readFileSync(path, "utf8"), course2);
1959
+ if (parsed.ok) out.push({ id: file.slice(0, -5), path, lastPlayed: statSync(path).mtimeMs, state: parsed.state });
1960
+ } catch {
1961
+ }
1962
+ }
1963
+ return out.sort((a, b) => b.lastPlayed - a.lastPlayed || b.id.localeCompare(a.id));
1964
+ }
1965
+ var stamp = (ms) => new Date(ms).toISOString().slice(0, 19).replace("T", "-").replace(/:/g, "");
1966
+ function newSessionPath(dir, now) {
1967
+ const base2 = stamp(now);
1968
+ let path = join(dir, `${base2}.json`);
1969
+ for (let n = 2; existsSync(path); n++) path = join(dir, `${base2}-${n}.json`);
1970
+ return path;
1971
+ }
1972
+ function migrateLegacySave(configRoot, courseId) {
1973
+ const legacy = join(configRoot, "silver-tongue", `${courseId}.json`);
1974
+ if (!existsSync(legacy)) return;
1975
+ const dir = sessionsDir(configRoot, courseId);
1976
+ mkdirSync(dir, { recursive: true });
1977
+ renameSync(legacy, newSessionPath(dir, statSync(legacy).mtimeMs));
1978
+ }
1979
+
1803
1980
  // src/storage.ts
1804
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
1981
+ import { closeSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync as renameSync2, writeSync } from "node:fs";
1805
1982
  import { homedir } from "node:os";
1806
- import { dirname, join } from "node:path";
1983
+ import { dirname, join as join2 } from "node:path";
1807
1984
  function configDir(env = process.env, platform = process.platform, home = homedir()) {
1808
- if (platform === "win32") return env.APPDATA || join(home, "AppData", "Roaming");
1809
- return env.XDG_CONFIG_HOME || join(home, ".config");
1985
+ if (platform === "win32") return env.APPDATA || join2(home, "AppData", "Roaming");
1986
+ return env.XDG_CONFIG_HOME || join2(home, ".config");
1810
1987
  }
1811
1988
  function loadSave(course2, path) {
1812
1989
  try {
1813
- if (!existsSync(path)) return { state: newGame(course2), readOnly: false };
1814
- const parsed = parseSave(readFileSync(path, "utf8"), course2);
1990
+ if (!existsSync2(path)) return { state: newGame(course2), readOnly: false };
1991
+ const parsed = parseSave(readFileSync2(path, "utf8"), course2);
1815
1992
  if (parsed.ok) return { state: parsed.state, readOnly: false };
1816
- renameSync(path, `${path}.invalid-backup`);
1993
+ renameSync2(path, `${path}.invalid-backup`);
1817
1994
  return { state: newGame(course2), notice: "notice-bad-save", readOnly: false };
1818
1995
  } catch {
1819
1996
  return { state: newGame(course2), notice: "notice-read-only", readOnly: true };
@@ -1821,7 +1998,7 @@ function loadSave(course2, path) {
1821
1998
  }
1822
1999
  function writeSave(path, state2) {
1823
2000
  try {
1824
- mkdirSync(dirname(path), { recursive: true });
2001
+ mkdirSync2(dirname(path), { recursive: true });
1825
2002
  const tmp = `${path}.tmp`;
1826
2003
  const fd = openSync(tmp, "w");
1827
2004
  try {
@@ -1830,7 +2007,7 @@ function writeSave(path, state2) {
1830
2007
  } finally {
1831
2008
  closeSync(fd);
1832
2009
  }
1833
- renameSync(tmp, path);
2010
+ renameSync2(tmp, path);
1834
2011
  return true;
1835
2012
  } catch {
1836
2013
  return false;
@@ -1839,13 +2016,13 @@ function writeSave(path, state2) {
1839
2016
 
1840
2017
  // src/main.ts
1841
2018
  var COURSE = "zh-china-en";
1842
- function coursePath() {
1843
- if (process.argv[2]) return process.argv[2];
2019
+ function coursePath(given) {
2020
+ if (given) return given;
1844
2021
  const candidates = [
1845
2022
  new URL(`./courses/${COURSE}/course.json`, import.meta.url),
1846
2023
  new URL(`../../../dist/courses/${COURSE}/course.json`, import.meta.url)
1847
2024
  ].map((u) => fileURLToPath(u));
1848
- const found = candidates.find((p) => existsSync2(p));
2025
+ const found = candidates.find((p) => existsSync3(p));
1849
2026
  if (!found) {
1850
2027
  console.error(`No built course found. Run: npm run build:course`);
1851
2028
  process.exit(1);
@@ -1857,8 +2034,48 @@ if (major < 22) {
1857
2034
  console.error(`silver-tongue needs Node 22 or newer; this is Node ${process.versions.node}.`);
1858
2035
  process.exit(1);
1859
2036
  }
1860
- var course = JSON.parse(readFileSync2(coursePath(), "utf8"));
1861
- var savePath = process.env.SILVER_TONGUE_SAVE ?? join2(configDir(), "silver-tongue", `${course.id}.json`);
2037
+ var flags = parseFlags(process.argv.slice(2));
2038
+ if (flags.mode === "help") {
2039
+ console.log(USAGE);
2040
+ process.exit(0);
2041
+ }
2042
+ if (flags.mode === "error") {
2043
+ console.error(`silver-tongue: ${flags.message}
2044
+
2045
+ ${USAGE}`);
2046
+ process.exit(2);
2047
+ }
2048
+ var course = JSON.parse(readFileSync3(coursePath(flags.coursePath), "utf8"));
2049
+ async function chooseSave() {
2050
+ if (process.env.SILVER_TONGUE_SAVE) return process.env.SILVER_TONGUE_SAVE;
2051
+ const root = configDir();
2052
+ const dir = sessionsDir(root, course.id);
2053
+ try {
2054
+ migrateLegacySave(root, course.id);
2055
+ } catch {
2056
+ }
2057
+ const sessions = listSessions(dir, course);
2058
+ if (flags.mode === "continue") return sessions[0]?.path ?? newSessionPath(dir, Date.now());
2059
+ if (flags.mode === "new") return newSessionPath(dir, Date.now());
2060
+ const t = makeText(course.learnerFtl);
2061
+ if (!sessions.length) {
2062
+ console.log(t("resume-none"));
2063
+ return newSessionPath(dir, Date.now());
2064
+ }
2065
+ console.log(t("resume-title"));
2066
+ for (const line of sessionLines(sessions, course, t, (ms) => new Date(ms).toLocaleString())) console.log(line);
2067
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2068
+ try {
2069
+ for (; ; ) {
2070
+ const pick = pickAnswer(await rl.question(`${t("resume-ask")} `), sessions.length);
2071
+ if (pick === "cancel") process.exit(0);
2072
+ if (pick !== "again") return sessions[pick].path;
2073
+ }
2074
+ } finally {
2075
+ rl.close();
2076
+ }
2077
+ }
2078
+ var savePath = await chooseSave();
1862
2079
  var { state, notice, readOnly } = loadSave(course, savePath);
1863
2080
  var core = createCore(course, state, { now: Date.now, rng: mulberry32(Date.now() >>> 0) });
1864
2081
  var term = createNodeTerminal();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silver-tongue",
3
- "version": "0.1.1",
3
+ "version": "0.3.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" },