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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/index.js +407 -0
- 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
|
+

|
|
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
|
+
}
|