regex-dungeon 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/index.js +407 -0
  4. package/package.json +38 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haneul-two
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # πŸ—‘οΈ regex-dungeon
2
+
3
+ ![regex-dungeon gameplay](assets/demo.gif)
4
+
5
+ A **zero-dependency terminal roguelike that teaches regex**. Each floor throws monsters at you β€” some you *must* slay, some you *must* spare β€” and you fight them with **one regular expression**. Match the right ones, miss the wrong ones, descend deeper.
6
+
7
+ > πŸ‡°πŸ‡· ν•œκ΅­μ–΄ μ„€λͺ…은 [μ•„λž˜](#-ν•œκ΅­μ–΄)에 μžˆμŠ΅λ‹ˆλ‹€.
8
+
9
+ ```
10
+ Floor 3/8 Anchors ^ $ β™₯β™₯β™₯β™‘β™‘
11
+ ^ = start, $ = end. Without them, a pattern matches substrings too.
12
+
13
+ β–² SLAY cat
14
+ β—‹ SPARE category
15
+ β—‹ SPARE scatter
16
+ β—‹ SPARE tomcat
17
+
18
+ your pattern β–Ά ^cat$
19
+
20
+ β–² cat βœ“ slain
21
+ β—‹ category βœ“ spared
22
+ β—‹ scatter βœ“ spared
23
+ β—‹ tomcat βœ“ spared
24
+ Floor cleared! +110
25
+ ```
26
+
27
+ One file, **zero dependencies**, runs with one command. Auto-detects your language (English / ν•œκ΅­μ–΄).
28
+
29
+ ## Play
30
+
31
+ Requires [Node.js](https://nodejs.org) β‰₯ 16. No install needed:
32
+
33
+ ```bash
34
+ npx regex-dungeon
35
+ ```
36
+
37
+ Or clone and run:
38
+
39
+ ```bash
40
+ git clone https://github.com/Haneul-two/regex-dungeon.git
41
+ cd regex-dungeon
42
+ node index.js
43
+ ```
44
+
45
+ ## How it works
46
+
47
+ - Each floor teaches **one regex concept** (literals β†’ `.`/classes β†’ anchors β†’ quantifiers β†’ optionals β†’ `\d{n}` β†’ groups/`|` β†’ a boss email).
48
+ - You type a single pattern. The game tests it against every monster:
49
+ - **β–² SLAY** monsters *must* match.
50
+ - **β—‹ SPARE** monsters *must not* match.
51
+ - Get it perfect β†’ floor cleared. Any mistake β†’ lose a heart (β™₯). Run out β†’ you die.
52
+ - **Shorter patterns score higher** (beat par), fewer attempts score higher.
53
+ - Stuck? Type `?` for a hint, or `??` to reveal the answer (that floor scores 0).
54
+
55
+ ```bash
56
+ regex-dungeon --lang ko # force Korean (or set BEAR_LANG=ko)
57
+ regex-dungeon --selftest # verify every floor is solvable
58
+ regex-dungeon --help
59
+ ```
60
+
61
+ ## Contributing floors
62
+
63
+ Floors live in the `FLOORS` array in `index.js`. A floor is just data:
64
+
65
+ ```js
66
+ {
67
+ concept: { en: 'Quantifier +', ko: 'μˆ˜λŸ‰μž +' },
68
+ lesson: { en: '`+` means "one or more"...', ko: '`+` λŠ” "1개 이상"...' },
69
+ slay: ['go', 'goo', 'goooo'], // MUST match
70
+ spare: ['g', 'ga', 'good'], // must NOT match
71
+ par: 6, // target pattern length
72
+ solution:'^go+$' // used by --selftest and the ?? reveal
73
+ }
74
+ ```
75
+
76
+ PRs with new floors are very welcome β€” run `npm test` (it's `--selftest`) to confirm your floor is solvable by its own `solution`, then open a PR.
77
+
78
+ ## License
79
+
80
+ MIT
81
+
82
+ ---
83
+
84
+ ## πŸ‡°πŸ‡· ν•œκ΅­μ–΄
85
+
86
+ **μ˜μ‘΄μ„± μ—†λŠ” 터미널 둜그라이크둜 μ •κ·œμ‹μ„ λ°°μš°λŠ” κ²Œμž„.** 각 μΈ΅λ§ˆλ‹€ λͺ¬μŠ€ν„°κ°€ λ‚˜μ˜€λŠ”λ°, μ–΄λ–€ λ†ˆμ€ *λ°˜λ“œμ‹œ 처치*ν•˜κ³  μ–΄λ–€ λ†ˆμ€ *λ°˜λ“œμ‹œ 살렀둬야* ν•©λ‹ˆλ‹€. λ¬΄κΈ°λŠ” **μ •κ·œμ‹ ν•˜λ‚˜**. λ§žμΆ°μ•Ό ν•  λ†ˆλ§Œ λ§€μΉ˜ν•˜κ³  μ—‰λš±ν•œ λ†ˆμ€ ν”Όν•˜λ©΄μ„œ λ˜μ „μ„ λ‚΄λ €κ°€μ„Έμš”. 단일 파일, μ˜μ‘΄μ„± 0, λͺ…λ Ήμ–΄ ν•œ 쀄. μ–Έμ–΄λŠ” μžλ™ 감지(μ˜μ–΄ / ν•œκ΅­μ–΄)λ©λ‹ˆλ‹€.
87
+
88
+ ```
89
+ 3/8 μΈ΅ 액컀 ^ $ β™₯β™₯β™₯β™‘β™‘
90
+ ^ μ‹œμž‘, $ 끝. μ—†μœΌλ©΄ λΆ€λΆ„ λ¬Έμžμ—΄λ„ 맀치돼 λ²„λ¦½λ‹ˆλ‹€.
91
+
92
+ β–² 처치 cat
93
+ β—‹ νšŒν”Ό category
94
+ β—‹ νšŒν”Ό scatter
95
+ β—‹ νšŒν”Ό tomcat
96
+
97
+ λ„€ νŒ¨ν„΄ β–Ά ^cat$
98
+ ...
99
+ μΈ΅ 클리어! +110
100
+ ```
101
+
102
+ ### ν”Œλ ˆμ΄
103
+
104
+ [Node.js](https://nodejs.org) β‰₯ 16 ν•„μš”. μ„€μΉ˜ 없이 μ‹€ν–‰:
105
+
106
+ ```bash
107
+ npx regex-dungeon
108
+ ```
109
+
110
+ ### κ·œμΉ™
111
+
112
+ - 각 측은 **μ •κ·œμ‹ κ°œλ… 1개**λ₯Ό κ°€λ₯΄μΉ©λ‹ˆλ‹€ (λ¦¬ν„°λŸ΄ β†’ `.`/문자클래슀 β†’ 액컀 β†’ μˆ˜λŸ‰μž β†’ 선택 β†’ `\d{n}` β†’ κ·Έλ£Ή/`|` β†’ 보슀 이메일).
113
+ - νŒ¨ν„΄ ν•˜λ‚˜λ₯Ό μž…λ ₯ν•˜λ©΄ λͺ¨λ“  λͺ¬μŠ€ν„°μ— λŒ€ν•΄ ν…ŒμŠ€νŠΈν•©λ‹ˆλ‹€.
114
+ - **β–² 처치** λͺ¬μŠ€ν„°λŠ” *λ°˜λ“œμ‹œ* λ§€μΉ˜λΌμ•Ό 함.
115
+ - **β—‹ νšŒν”Ό** λͺ¬μŠ€ν„°λŠ” *μ ˆλŒ€* 맀치되면 μ•ˆ 됨.
116
+ - μ™„λ²½ν•˜λ©΄ μΈ΅ 클리어. μ‹€μˆ˜ν•˜λ©΄ ν•˜νŠΈ(β™₯) κ°μ†Œ. λ‹€ μžƒμœΌλ©΄ 사망.
117
+ - **νŒ¨ν„΄μ΄ μ§§μ„μˆ˜λ‘**, μ‹œλ„κ°€ μ μ„μˆ˜λ‘ μ μˆ˜κ°€ λ†’μŠ΅λ‹ˆλ‹€.
118
+ - λ§‰νžˆλ©΄ `?` 둜 힌트, `??` 둜 μ •λ‹΅ 곡개(ν•΄λ‹Ή μΈ΅ 0점).
119
+
120
+ ```bash
121
+ regex-dungeon --lang ko # ν•œκ΅­μ–΄ κ°•μ œ (λ˜λŠ” BEAR_LANG=ko)
122
+ regex-dungeon --selftest # λͺ¨λ“  측이 ν’€λ¦¬λŠ”μ§€ 검증
123
+ ```
124
+
125
+ ### μΈ΅ κΈ°μ—¬ν•˜κΈ°
126
+
127
+ 측은 `index.js` 의 `FLOORS` 배열에 λ°μ΄ν„°λ‘œ λ“€μ–΄ μžˆμŠ΅λ‹ˆλ‹€. μƒˆ μΈ΅ PR을 ν™˜μ˜ν•©λ‹ˆλ‹€ β€” `npm test`(=`--selftest`)둜 본인 측이 `solution` 으둜 ν’€λ¦¬λŠ”μ§€ 확인 ν›„ PR ν•΄μ£Όμ„Έμš”.
128
+
129
+ ### License
130
+
131
+ MIT
package/index.js ADDED
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * regex-dungeon β€” a zero-dependency terminal roguelike that teaches regex.
6
+ * Descend the dungeon; slay the monsters that must die, spare the rest,
7
+ * using one regular expression per floor. MIT licensed.
8
+ */
9
+
10
+ const readline = require('readline');
11
+
12
+ // ─── colors ────────────────────────────────────────────────────────────────
13
+ const NO_COLOR = process.env.NO_COLOR || !process.stdout.isTTY;
14
+ const c = (code) => (s) => (NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`);
15
+ const red = c('31'), green = c('32'), yellow = c('33');
16
+ const cyan = c('36'), magenta = c('35'), dim = c('2'), bold = c('1');
17
+
18
+ // ─── i18n ────────────────────────────────────────────────────────────────--
19
+ const LANG =
20
+ (process.argv.includes('--lang')
21
+ ? process.argv[process.argv.indexOf('--lang') + 1]
22
+ : (process.env.BEAR_LANG || process.env.LANG || 'en')
23
+ ).toLowerCase().startsWith('ko')
24
+ ? 'ko'
25
+ : 'en';
26
+
27
+ const T = {
28
+ en: {
29
+ title: 'REGEX DUNGEON',
30
+ subtitle: 'slay with a single regex',
31
+ floor: 'Floor',
32
+ slay: 'SLAY',
33
+ spare: 'SPARE',
34
+ prompt: 'your pattern',
35
+ slain: 'slain',
36
+ missed: 'missed!',
37
+ spared: 'spared',
38
+ hit: 'HIT!',
39
+ cleared: 'Floor cleared!',
40
+ invalid: 'Invalid regex β€” that spell fizzles. (no damage)',
41
+ damage: 'You took 1 damage.',
42
+ hintTip: "Type ? for a hint, ?? to reveal the answer (no score).",
43
+ hint: 'Hint',
44
+ answer: 'Answer',
45
+ gameover: 'YOU DIED',
46
+ victory: 'YOU CLEARED THE DUNGEON',
47
+ floorsCleared: 'Floors cleared',
48
+ score: 'Score',
49
+ share: 'Share',
50
+ shareMsg: (n, total) =>
51
+ `I cleared ${n}/${total} floors of regex-dungeon! πŸ—‘οΈ`,
52
+ playAgain: 'Run `npx regex-dungeon` to try again.',
53
+ quitHint: '(ctrl+c to quit)',
54
+ },
55
+ ko: {
56
+ title: 'μ •κ·œμ‹ λ˜μ „',
57
+ subtitle: 'μ •κ·œμ‹ ν•˜λ‚˜λ‘œ μ²˜μΉ˜ν•˜λΌ',
58
+ floor: 'μΈ΅',
59
+ slay: '처치',
60
+ spare: 'νšŒν”Ό',
61
+ prompt: 'λ„€ νŒ¨ν„΄',
62
+ slain: '처치됨',
63
+ missed: '놓침!',
64
+ spared: 'νšŒν”Όλ¨',
65
+ hit: '였격!',
66
+ cleared: 'μΈ΅ 클리어!',
67
+ invalid: '잘λͺ»λœ μ •κ·œμ‹ β€” 주문이 λΆˆλ°œλλ‹€. (ν”Όν•΄ μ—†μŒ)',
68
+ damage: 'ν”Όν•΄ 1을 μž…μ—ˆλ‹€.',
69
+ hintTip: '? μž…λ ₯ μ‹œ 힌트, ?? μž…λ ₯ μ‹œ μ •λ‹΅ 곡개(점수 μ—†μŒ).',
70
+ hint: '힌트',
71
+ answer: 'μ •λ‹΅',
72
+ gameover: '당신은 μ£½μ—ˆλ‹€',
73
+ victory: 'λ˜μ „μ„ ν΄λ¦¬μ–΄ν–ˆλ‹€',
74
+ floorsCleared: 'ν΄λ¦¬μ–΄ν•œ μΈ΅',
75
+ score: '점수',
76
+ share: '곡유',
77
+ shareMsg: (n, total) => `regex-dungeon ${total}μΈ΅ 쀑 ${n}μΈ΅ 클리어! πŸ—‘οΈ`,
78
+ playAgain: 'λ‹€μ‹œ ν•˜λ €λ©΄ `npx regex-dungeon` μ‹€ν–‰.',
79
+ quitHint: '(ctrl+c 둜 μ’…λ£Œ)',
80
+ },
81
+ }[LANG];
82
+
83
+ // ─── floors ──────────────────────────────────────────────────────────────--
84
+ // Each floor teaches one concept. `slay` MUST match, `spare` must NOT match.
85
+ // `par` is the target pattern length (shorter than par earns bonus points).
86
+ // `solution` is used for the --selftest and the ?? reveal.
87
+ const FLOORS = [
88
+ {
89
+ concept: { en: 'Literals', ko: 'λ¦¬ν„°λŸ΄' },
90
+ lesson: {
91
+ en: 'A regex with no special chars matches that exact text anywhere in a string.',
92
+ ko: '특수문자 μ—†λŠ” μ •κ·œμ‹μ€ κ·Έ κΈ€μžκ°€ λ¬Έμžμ—΄ μ–΄λ”˜κ°€μ— 있으면 λ§€μΉ˜λ©λ‹ˆλ‹€.',
93
+ },
94
+ slay: ['cat'],
95
+ spare: ['dog', 'cap', 'bat'],
96
+ par: 3,
97
+ solution: 'cat',
98
+ },
99
+ {
100
+ concept: { en: 'The dot & classes', ko: '점(.)과 문자클래슀' },
101
+ lesson: {
102
+ en: '`.` matches any one char; `[aou]` matches any one char in the set.',
103
+ ko: '`.` λŠ” 아무 κΈ€μž ν•˜λ‚˜, `[aou]` λŠ” μ§‘ν•© μ•ˆμ˜ κΈ€μž ν•˜λ‚˜μ™€ λ§€μΉ˜λ©λ‹ˆλ‹€.',
104
+ },
105
+ slay: ['cat', 'cot', 'cut'],
106
+ spare: ['cap', 'cog', 'dog'],
107
+ par: 5,
108
+ solution: 'c[aou]t',
109
+ },
110
+ {
111
+ concept: { en: 'Anchors ^ $', ko: '액컀 ^ $' },
112
+ lesson: {
113
+ en: '`^` = start, `$` = end. Without them, a pattern matches substrings too.',
114
+ ko: '`^` μ‹œμž‘, `$` 끝. μ—†μœΌλ©΄ λΆ€λΆ„ λ¬Έμžμ—΄λ„ 맀치돼 λ²„λ¦½λ‹ˆλ‹€.',
115
+ },
116
+ slay: ['cat'],
117
+ spare: ['category', 'scatter', 'tomcat'],
118
+ par: 5,
119
+ solution: '^cat$',
120
+ },
121
+ {
122
+ concept: { en: 'Quantifier +', ko: 'μˆ˜λŸ‰μž +' },
123
+ lesson: {
124
+ en: '`+` means "one or more of the previous". `go+` = g then 1+ o.',
125
+ ko: '`+` λŠ” "μ•ž κΈ€μž 1개 이상". `go+` = g λ‹€μŒ o 1개 이상.',
126
+ },
127
+ slay: ['go', 'goo', 'goooo'],
128
+ spare: ['g', 'ga', 'good'],
129
+ par: 6,
130
+ solution: '^go+$',
131
+ },
132
+ {
133
+ concept: { en: 'Optional ?', ko: '선택 ?' },
134
+ lesson: {
135
+ en: '`?` makes the previous char optional. Great for spelling variants.',
136
+ ko: '`?` λŠ” μ•ž κΈ€μžλ₯Ό μ„ νƒμ μœΌλ‘œ. 철자 λ³€ν˜•μ— μœ μš©ν•©λ‹ˆλ‹€.',
137
+ },
138
+ slay: ['color', 'colour'],
139
+ spare: ['collar', 'coler'],
140
+ par: 8,
141
+ solution: '^colou?r$',
142
+ },
143
+ {
144
+ concept: { en: 'Digits & {n}', ko: '숫자 \\d 와 {n}' },
145
+ lesson: {
146
+ en: '`\\d` = a digit, `{4}` = exactly four of them. Perfect for years.',
147
+ ko: '`\\d` 숫자 ν•˜λ‚˜, `{4}` μ •ν™•νžˆ 4개. 연도에 λ”± λ§žμŠ΅λ‹ˆλ‹€.',
148
+ },
149
+ slay: ['2026', '1999'],
150
+ spare: ['20', 'abcd', '12a4'],
151
+ par: 7,
152
+ solution: '^\\d{4}$',
153
+ },
154
+ {
155
+ concept: { en: 'Groups & | (OR)', ko: 'κ·Έλ£Ήκ³Ό | (OR)' },
156
+ lesson: {
157
+ en: '`(cat|dog)` matches cat OR dog. Parens group; `|` is alternation.',
158
+ ko: '`(cat|dog)` λŠ” cat λ˜λŠ” dog. κ΄„ν˜Έλ‘œ λ¬Άκ³  `|` 둜 μ–‘μžνƒμΌ.',
159
+ },
160
+ slay: ['cat', 'dog'],
161
+ spare: ['cow', 'fox'],
162
+ par: 11,
163
+ solution: '^(cat|dog)$',
164
+ },
165
+ {
166
+ concept: { en: 'BOSS β€” Email', ko: '보슀 β€” 이메일' },
167
+ lesson: {
168
+ en: 'Combine it all: `\\S` = non-space. Match something@something.something.',
169
+ ko: '총동원: `\\S` λŠ” 곡백 μ•„λ‹Œ κΈ€μž. something@something.something 맀치.',
170
+ },
171
+ slay: ['a@b.com', 'jane.doe@mail.io'],
172
+ spare: ['a@b', '@b.com', 'ab.com', 'a b@c.com'],
173
+ par: 14,
174
+ solution: '^\\S+@\\S+\\.\\S+$',
175
+ boss: true,
176
+ },
177
+ ];
178
+
179
+ // ─── core logic (pure, used by game + selftest) ──────────────────────────────
180
+ function evaluate(pattern, floor) {
181
+ let re;
182
+ try {
183
+ re = new RegExp(pattern);
184
+ } catch (e) {
185
+ return { valid: false };
186
+ }
187
+ const rows = [
188
+ ...floor.slay.map((s) => ({ s, target: true })),
189
+ ...floor.spare.map((s) => ({ s, target: false })),
190
+ ].map((row) => {
191
+ const matched = re.test(row.s);
192
+ return { ...row, matched, correct: matched === row.target };
193
+ });
194
+ const mistakes = rows.filter((r) => !r.correct).length;
195
+ return { valid: true, rows, mistakes, cleared: mistakes === 0 };
196
+ }
197
+
198
+ // ─── rendering ───────────────────────────────────────────────────────────--
199
+ function banner() {
200
+ const line = '═'.repeat(40);
201
+ console.log(magenta(`β•”${line}β•—`));
202
+ console.log(magenta('β•‘') + bold(center(`πŸ—‘οΈ ${T.title} πŸ—‘οΈ`, 40)) + magenta('β•‘'));
203
+ console.log(magenta('β•‘') + dim(center(T.subtitle, 40)) + magenta('β•‘'));
204
+ console.log(magenta(`β•š${line}╝`));
205
+ }
206
+
207
+ function center(s, w) {
208
+ // width-aware enough for our ASCII-ish content
209
+ const len = [...s].length + (s.match(/πŸ—‘οΈ/g) || []).length; // emojis ~2 cols
210
+ const pad = Math.max(0, w - len);
211
+ const left = Math.floor(pad / 2);
212
+ return ' '.repeat(left) + s + ' '.repeat(pad - left);
213
+ }
214
+
215
+ function hearts(hp, max) {
216
+ return red('β™₯'.repeat(hp)) + dim('β™‘'.repeat(max - hp));
217
+ }
218
+
219
+ function showFloor(i, floor, hp, maxHp) {
220
+ console.log();
221
+ console.log(
222
+ bold(`${T.floor} ${i + 1}/${FLOORS.length}`) +
223
+ ' ' +
224
+ cyan(floor.concept[LANG]) +
225
+ ' ' +
226
+ hearts(hp, maxHp)
227
+ );
228
+ console.log(dim(' ' + floor.lesson[LANG]));
229
+ console.log();
230
+ for (const s of floor.slay) console.log(' ' + red('β–² ' + T.slay) + ' ' + bold(s));
231
+ for (const s of floor.spare) console.log(' ' + dim('β—‹ ' + T.spare) + ' ' + dim(s));
232
+ console.log();
233
+ console.log(dim(' ' + T.hintTip));
234
+ }
235
+
236
+ function showResult(result) {
237
+ console.log();
238
+ for (const r of result.rows) {
239
+ const ok = r.correct;
240
+ let verb;
241
+ if (r.target) verb = r.matched ? green('βœ“ ' + T.slain) : red('βœ— ' + T.missed);
242
+ else verb = r.matched ? red('βœ— ' + T.hit) : green('βœ“ ' + T.spared);
243
+ const mark = r.target ? red('β–²') : dim('β—‹');
244
+ console.log(' ' + mark + ' ' + (ok ? r.s : bold(r.s)).padEnd(18) + verb);
245
+ }
246
+ console.log();
247
+ }
248
+
249
+ // ─── game loop ───────────────────────────────────────────────────────────--
250
+ async function play() {
251
+ banner();
252
+ const maxHp = 5;
253
+ let hp = maxHp;
254
+ let score = 0;
255
+ let cleared = 0;
256
+
257
+ // Queue-based line reader: robust to buffered/piped input as well as
258
+ // interactive typing (rl.question would drop lines that arrive between asks).
259
+ const rl = readline.createInterface({ input: process.stdin });
260
+ const queue = [];
261
+ let waiting = null;
262
+ let closed = false;
263
+ rl.on('line', (line) => {
264
+ if (waiting) {
265
+ const w = waiting;
266
+ waiting = null;
267
+ w(line);
268
+ } else queue.push(line);
269
+ });
270
+ rl.on('close', () => {
271
+ closed = true;
272
+ if (waiting) {
273
+ const w = waiting;
274
+ waiting = null;
275
+ w(null);
276
+ }
277
+ });
278
+ const ask = (q) =>
279
+ new Promise((res) => {
280
+ process.stdout.write(q);
281
+ if (queue.length) res(queue.shift());
282
+ else if (closed) res(null);
283
+ else waiting = res;
284
+ });
285
+
286
+ for (let i = 0; i < FLOORS.length; i++) {
287
+ const floor = FLOORS[i];
288
+ showFloor(i, floor, hp, maxHp);
289
+ let attempts = 0;
290
+ let usedReveal = false;
291
+ let floorDone = false;
292
+
293
+ while (!floorDone) {
294
+ const raw = await ask('\n ' + cyan(T.prompt) + ' β–Ά ');
295
+ if (raw === null) {
296
+ // stdin closed (ctrl+D / end of piped input) β€” bail out gracefully
297
+ rl.close();
298
+ return endCard(false, cleared, score);
299
+ }
300
+ const ans = raw.trim();
301
+
302
+ if (ans === '?') {
303
+ console.log(' ' + yellow(T.hint + ': ') + floor.lesson[LANG]);
304
+ continue;
305
+ }
306
+ if (ans === '??') {
307
+ usedReveal = true;
308
+ console.log(' ' + yellow(T.answer + ': ') + bold(floor.solution));
309
+ continue;
310
+ }
311
+ if (ans === '') continue;
312
+
313
+ attempts++;
314
+ const result = evaluate(ans, floor);
315
+ if (!result.valid) {
316
+ console.log(' ' + yellow(T.invalid));
317
+ attempts--; // a fizzle costs nothing
318
+ continue;
319
+ }
320
+ showResult(result);
321
+
322
+ if (result.cleared) {
323
+ const parBonus = Math.max(0, floor.par - ans.length) * 5;
324
+ const attemptPenalty = (attempts - 1) * 10;
325
+ let gain = usedReveal ? 0 : Math.max(10, 100 + parBonus - attemptPenalty);
326
+ score += gain;
327
+ cleared++;
328
+ console.log(' ' + green(bold(T.cleared)) + dim(` +${gain}`));
329
+ floorDone = true;
330
+ } else {
331
+ hp -= 1;
332
+ console.log(' ' + red(T.damage) + ' ' + hearts(hp, maxHp));
333
+ if (hp <= 0) {
334
+ rl.close();
335
+ return endCard(false, cleared, score);
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ rl.close();
342
+ return endCard(true, cleared, score);
343
+ }
344
+
345
+ function endCard(won, cleared, score) {
346
+ const line = '═'.repeat(40);
347
+ console.log();
348
+ console.log(magenta(`β•”${line}β•—`));
349
+ console.log(
350
+ magenta('β•‘') +
351
+ bold(center(won ? 'πŸ† ' + T.victory : 'πŸ’€ ' + T.gameover, 40)) +
352
+ magenta('β•‘')
353
+ );
354
+ console.log(magenta('β•‘') + center('', 40) + magenta('β•‘'));
355
+ console.log(
356
+ magenta('β•‘') +
357
+ center(`${T.floorsCleared}: ${cleared}/${FLOORS.length}`, 40) +
358
+ magenta('β•‘')
359
+ );
360
+ console.log(magenta('β•‘') + center(`${T.score}: ${score}`, 40) + magenta('β•‘'));
361
+ console.log(magenta(`β•š${line}╝`));
362
+ console.log();
363
+ console.log(' ' + dim(T.share + ': ') + T.shareMsg(cleared, FLOORS.length));
364
+ console.log(' ' + dim('β–Ά github.com/Haneul-two/regex-dungeon'));
365
+ console.log(' ' + dim(T.playAgain));
366
+ console.log();
367
+ }
368
+
369
+ // ─── selftest: verify every floor is solvable by its intended answer ─────────
370
+ function selftest() {
371
+ let pass = 0;
372
+ for (let i = 0; i < FLOORS.length; i++) {
373
+ const f = FLOORS[i];
374
+ const r = evaluate(f.solution, f);
375
+ const ok = r.valid && r.cleared;
376
+ console.log(
377
+ `${ok ? green('PASS') : red('FAIL')} Floor ${i + 1} ${f.concept.en} /${f.solution}/`
378
+ );
379
+ if (ok) pass++;
380
+ else if (r.valid) {
381
+ for (const row of r.rows.filter((x) => !x.correct))
382
+ console.log(` ${row.target ? 'should match' : 'should NOT match'}: "${row.s}"`);
383
+ }
384
+ }
385
+ console.log(`\n${pass}/${FLOORS.length} floors valid`);
386
+ process.exit(pass === FLOORS.length ? 0 : 1);
387
+ }
388
+
389
+ // ─── entry ───────────────────────────────────────────────────────────────--
390
+ if (process.argv.includes('--selftest')) {
391
+ selftest();
392
+ } else if (process.argv.includes('--help') || process.argv.includes('-h')) {
393
+ console.log(`regex-dungeon β€” learn regex by playing in your terminal.
394
+
395
+ Usage:
396
+ npx regex-dungeon play the game
397
+ regex-dungeon --lang ko force Korean (or BEAR_LANG=ko)
398
+ regex-dungeon --selftest verify all floors are solvable
399
+ regex-dungeon --help show this help`);
400
+ } else {
401
+ play().catch((e) => {
402
+ console.error(e);
403
+ process.exit(1);
404
+ });
405
+ }
406
+
407
+ module.exports = { evaluate, FLOORS };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "regex-dungeon",
3
+ "version": "0.1.0",
4
+ "description": "A zero-dependency terminal roguelike that teaches regex β€” slay the monsters that must die, spare the rest, with one regular expression per floor.",
5
+ "bin": {
6
+ "regex-dungeon": "index.js"
7
+ },
8
+ "scripts": {
9
+ "start": "node index.js",
10
+ "test": "node index.js --selftest"
11
+ },
12
+ "keywords": [
13
+ "regex",
14
+ "regexp",
15
+ "game",
16
+ "cli",
17
+ "terminal",
18
+ "roguelike",
19
+ "learn",
20
+ "education",
21
+ "tui",
22
+ "interactive"
23
+ ],
24
+ "author": "Haneul-two",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/Haneul-two/regex-dungeon.git"
29
+ },
30
+ "engines": {
31
+ "node": ">=16"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "README.md",
36
+ "LICENSE"
37
+ ]
38
+ }