feat: age-aware solver, verify-levels, seven solver-proved SpellGrid levels (l4-l10)
The solver's position key reads stackAt (each tile oldest first). New tool tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each level's shortest solution, and that it exhausts without its idea (neverRule) or on the naive engine. Levels 4-10, one course idea each, with test_10_level_N_solution in both tracks, scenarios and Lesson 10's list; l1 no longer matches l10, and proofs.json is not listed as a level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4eaf24eed2
commit
570e5d6826
22 files changed
+1196
-42
No files matched your search
@@ -42,7 +42,7 @@ node tools/start-module.mjs 01 ~/my-engine
|
||||
|
||||
`start-module` copies a module's tests for your track (from your workspace's `.course.json`) and refuses to start a module if you have not yet taken the previous one. Module 00 uses the `naive-engine/` project and is not started this way, and Module 13 has no tests to start.
|
||||
|
||||
Other tools: `tools/coupling-audit.mjs <workspace>` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs <workspace> <baseRef>` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests; `--expansion` also checks every Expansion Drop solution), `tools/play-spellgrid.mjs <rust|ts> [l1|l2|l3]` (play the Module 10 capstone, SpellGrid, on the command line with the reference engine), `tools/solve-spellgrid.mjs [l1|l2|l3|level.json]` (search a SpellGrid level for its shortest solution by brute force; `--never-rule "X IS Y"` asks whether it can be solved without a given rule; see Module 13), `tools/diff-tracks.mjs [--seeds N] [--seed S] [--module NN] [--dir D] [--keep]` (differential fuzzer: plays random SpellGrid levels and scripts on the Rust and the TypeScript reference CLIs and fails on the first seed where they print different text or one of them crashes; `npm run diff:tracks`).
|
||||
Other tools: `tools/coupling-audit.mjs <workspace>` (engine code must not name specific cards or nouns), `tools/expansion-check.mjs <workspace> <baseRef>` (an Expansion Drop must change content only), `tools/verify-references.mjs [NN]` (reference solutions pass the tests; `--expansion` also checks every Expansion Drop solution), `tools/play-spellgrid.mjs <rust|ts> [l1..l10]` (play the Module 10 capstone, SpellGrid, on the command line with the reference engine), `tools/solve-spellgrid.mjs [l1..l10|level.json]` (search a SpellGrid level for its shortest solution by brute force; `--never-rule "X IS Y"` asks whether it can be solved without a given rule; see Module 13), `tools/verify-levels.mjs [level ...]` (checks every claim in `course/10-spellgrid/levels/proofs.json` with the solver: each level's shortest solution, and that it can't be solved without its idea; `npm run verify:levels`), `tools/diff-tracks.mjs [--seeds N] [--seed S] [--module NN] [--dir D] [--keep]` (differential fuzzer: plays random SpellGrid levels and scripts on the Rust and the TypeScript reference CLIs and fails on the first seed where they print different text or one of them crashes; `npm run diff:tracks`).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -61,11 +61,11 @@ From the course repository, copy the module's tests and levels into your workspa
|
||||
node tools/start-module.mjs 10 ~/my-engine
|
||||
```
|
||||
|
||||
It copies `tests/test_10.*`, SpellGrid's cards into `cards/`, and the three levels into `levels/`. The tests load them from there.
|
||||
It copies `tests/test_10.*`, SpellGrid's cards into `cards/`, and the ten levels into `levels/`. The tests load them from there.
|
||||
|
||||
### The harness
|
||||
|
||||
One constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`. It has every Part II function (`place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost`), and:
|
||||
One constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`. It has every Part II function (`place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `stackAt`, `step`, `won`, `lost`), and:
|
||||
|
||||
| TypeScript | Rust |
|
||||
|---|---|
|
||||
@@ -128,11 +128,12 @@ The full statements are in `course/10-spellgrid/scenarios.md`.
|
||||
- `test_10_summoned_minion_enters_grid`: seam 4. The Clay Golem dies; its Clay Shard stands on the golem's tile, with the newest id, and `SHARD IS PUSH` names it straight after the cast.
|
||||
- `test_10_summon_without_noun_stays_off_grid`: seam 4's other half. A grafted Clay Golem with Harvest Golem's Deathrattle summons a Damaged Golem, which has no noun: it is in play, and the golem's tile is empty.
|
||||
- `test_10_level_1_solution`, `test_10_level_2_solution`, `test_10_level_3_solution`: each level, played from a script to `won()`.
|
||||
- `test_10_level_4_solution` to `test_10_level_10_solution`: levels 4 to 10, each played from the solver's shortest solution to `won()`, with one check on the way that the level's idea happened.
|
||||
- `test_10_level_3_naive_would_differ`: level 3 with `withNaiveAuraTiming()`. Same inputs, and the knight dies.
|
||||
|
||||
### The levels
|
||||
|
||||
Three puzzles in `course/10-spellgrid/levels/`. Capitals are text, lower case are objects, `#` is a wall. You can play each one (see [Play it](#play-it)).
|
||||
Ten puzzles in `course/10-spellgrid/levels/`. Capitals are text, lower case are objects (a minion by its noun), `#` is a wall, and `+` joins two objects on one tile. You can play each one (see [Play it](#play-it)). Levels 1 to 3 are worked through below and in the tests. Levels 4 to 10 each turn one idea from the course into a puzzle; here you get the idea, not the solution (that is in the tests, if you want it).
|
||||
|
||||
**Level 1, "Double-Tap".** The bramble (3 Health, `STOP`) is the only way to the flag. Whirlwind does 1 and the keg's Deathrattle 1 more: one short. Push `DOUBLE` up through the gap to finish `DEATHRATTLE IS DOUBLE` first. Hand: Whirlwind.
|
||||
|
||||
@@ -168,9 +169,91 @@ knight . . . hedge flag #
|
||||
. keg warden . # # #
|
||||
```
|
||||
|
||||
**Level 4, "Crossed Out".** `DEATHRATTLE IS DOUBLE` is already on the board, and a `NOT` sentence cancels it (Module 7). The hedge has 2 Health; the keg's Deathrattle does 1. Hand: Spark.
|
||||
|
||||
```text
|
||||
BABA IS YOU . FLAG IS WIN
|
||||
HEDGE IS STOP . WALL IS STOP
|
||||
# # # # # # #
|
||||
. . baba . . # flag
|
||||
DEATHRATTLE IS NOT DOUBLE . . hedge
|
||||
IS . . . . . #
|
||||
DOUBLE keg . . . . #
|
||||
```
|
||||
|
||||
**Level 5, "Sealed Keg".** `KEG IS FLAG` would make a flag, but `KEG IS KEG` locks the keg (Module 7), and the lock is out of reach. What does `KEG IS NOT KEG` do to a lock? Hand: empty.
|
||||
|
||||
```text
|
||||
BABA IS YOU . WALL IS STOP
|
||||
KEG IS KEG . KEG IS FLAG
|
||||
FLAG IS WIN . . . .
|
||||
# # # # # # #
|
||||
KEG IS . KEG . . .
|
||||
. keg . NOT . baba .
|
||||
. . . . . . .
|
||||
```
|
||||
|
||||
**Level 6, "Melt Before Defeat".** You are the Tin Knight. A keg stands on a shard that is `DEFEAT`, and the only way to the flag goes through that tile. Which hazard runs first, and what can a Deathrattle do in between? (Stress it question 1.) Hand: empty.
|
||||
|
||||
```text
|
||||
KNIGHT IS YOU . FLAG IS WIN
|
||||
WALL IS STOP . SHARD IS DEFEAT
|
||||
KEG IS PUSH AND MELT . .
|
||||
# # # # # # #
|
||||
LAVA IS . . . . .
|
||||
. . HOT . # # #
|
||||
knight . . keg+shard lava flag #
|
||||
```
|
||||
|
||||
**Level 7, "Clay Key".** The door needs a key that is `OPEN`, and there is nothing to be one but the shard a Clay Golem leaves behind (seam 4). Hand: Spark, Spark.
|
||||
|
||||
```text
|
||||
BABA IS YOU . FLAG IS WIN
|
||||
WALL IS STOP . SHARD IS PUSH
|
||||
DOOR IS SHUT AND STOP . .
|
||||
# # # # # # #
|
||||
SHARD IS . . . # flag
|
||||
. . OPEN . golem . door
|
||||
. baba . . . # #
|
||||
```
|
||||
|
||||
**Level 8, "Nothing Dies".** You need a shard, and nothing on this level can kill a minion. Graft can replace either half of a card's text (Module 5). Hand: Graft.
|
||||
|
||||
```text
|
||||
BABA IS YOU . .
|
||||
WALL IS STOP . .
|
||||
# # # # #
|
||||
SHARD IS . WIN baba
|
||||
. . . . .
|
||||
```
|
||||
|
||||
**Level 9, "Skull River".** Two skulls deep, two rocks, and no `PULL` to bring a rock back: one push moves a whole chain (Module 8), and `SINK` takes whatever shares its tile at the end of the turn (Module 9). Hand: empty.
|
||||
|
||||
```text
|
||||
BABA IS YOU . WALL IS STOP
|
||||
ROCK IS PUSH . FLAG IS WIN
|
||||
SKULL IS DEFEAT . . . .
|
||||
# # # # # # #
|
||||
SKULL . # # skull skull #
|
||||
IS baba rock rock skull skull flag
|
||||
. SINK . # skull skull #
|
||||
```
|
||||
|
||||
**Level 10, "Friendly Fire".** Whirlwind and the keg clear the hedge, as in level 3. But you are a Clay Shard with 1 Health. Hand: Whirlwind.
|
||||
|
||||
```text
|
||||
FLAG IS WIN . WALL IS STOP
|
||||
HEDGE IS STOP . . . .
|
||||
# # # # # # #
|
||||
. . . . . # flag
|
||||
SHARD IS YOU . . . hedge
|
||||
KNIGHT . keg . . knight .
|
||||
. shard . . . . .
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Make the twelve tests pass, and keep Modules 1–9 green. The shape to aim for:
|
||||
Make the nineteen tests pass, and keep Modules 1–9 green. The shape to aim for:
|
||||
|
||||
```text
|
||||
Part I: the card engine Part II: the grid engine
|
||||
@@ -269,7 +352,7 @@ node tools/play-spellgrid.mjs rust l1 # Rust: cargo run --bin spellgrid --
|
||||
node tools/play-spellgrid.mjs ts l1 # TypeScript: npx tsx src/cli.ts levels/l1-double-tap.json
|
||||
```
|
||||
|
||||
The level is `l1`, `l2` or `l3` (or a path to a level file of your own). `--dir <dir>` stages into a directory you choose and keeps it, and `--stage-only` prints the command without running it. The tool prints where it staged, so you can run the command there again.
|
||||
The level is `l1` to `l10` (or a path to a level file of your own). `--dir <dir>` stages into a directory you choose and keeps it, and `--stage-only` prints the command without running it. The tool prints where it staged, so you can run the command there again.
|
||||
|
||||
`w`, `a`, `s`, `d` play a turn; `c <card> [x y]` casts a spell (at the minion on that tile, if given) or plays a minion (onto that empty tile); `q` quits. Graft asks for its base, donor and half; answer with a number or a name. After a command that ran Part I Sequences, it prints their events under `Log:` as an indented tree (`logTree()`, from Module 2). Writing your own is optional: the course's tests don't call it. The Rust and the TypeScript command lines print the same text for the same input, and `node tools/diff-tracks.mjs` checks that on random levels and scripts.
|
||||
|
||||
@@ -435,7 +518,7 @@ The point of the exercise is the list, not the code. A seam is cheap to write on
|
||||
|
||||
</details>
|
||||
|
||||
4. The course ships three levels, each with one intended solution. Are they sound: is each solvable, and only in the intended way? How would you know?
|
||||
4. The course ships ten levels, each with one intended solution. Are they sound: is each solvable, and only in the intended way? How would you know?
|
||||
|
||||
<details><summary>Discussion</summary>
|
||||
|
||||
@@ -450,10 +533,14 @@ node tools/solve-spellgrid.mjs l3 --naive
|
||||
What it reports for the shipped levels:
|
||||
|
||||
- **Level 1** has no solution shorter than 10 inputs, the length of the scripted one. With every position where `DEATHRATTLE IS DOUBLE` is in force thrown away, the search runs out of positions (622 of them, the deepest 17 inputs in) without winning. The level can't be solved without the rule it was built to teach.
|
||||
- **Level 2** has no solution shorter than 4 inputs, and among some 31,000 positions within 4 inputs exactly one wins: the intended graft, played and pushed into the lava.
|
||||
- **Level 2** has no solution shorter than 4 inputs, and among some 18,600 positions within 4 inputs exactly one wins: the intended graft, played and pushed into the lava.
|
||||
- **Level 3** is solved in 6 inputs, and under `--naive` the search exhausts all 11 reachable positions with no win. "The knight dies" is not only what the one scripted test shows; no other line of play saves the naive engine.
|
||||
|
||||
An unintended solution is called a *cook*, and a level with one doesn't teach what it was built to teach. [Module 13](../13-break-your-engine/lesson.md) explains how the solver works and what it can't tell you. Then design a fourth level, and try to cook it before the tool does.
|
||||
- **Levels 4 to 10** were designed against the solver: each was changed until its shortest solution was the intended one and the search, with the level's idea forbidden, ran out of positions. The idea is forbidden as a rule the player has to form: `DEATHRATTLE IS DOUBLE` (level 4), `KEG IS NOT KEG` (5), `LAVA IS HOT` (6), `SHARD IS OPEN` (7), `SHARD IS WIN` (8), `SKULL IS SINK` (9), `KNIGHT IS YOU` (10).
|
||||
|
||||
Each of these claims is written down in `course/10-spellgrid/levels/proofs.json`, one entry per level (`{ "level": "l4-crossed-out", "shortest": 7, "neverRule": "DEATHRATTLE IS DOUBLE" }`, or `"naiveUnsolvable": true` for level 3), and `node tools/verify-levels.mjs` (`npm run verify:levels`) checks every one with the solver. A forbidden rule is the closest a rule filter gets to "the idea": for level 6 it proves the keg has to melt, and the map does the rest, because the only push that sends the keg into the lava puts the knight on the shard.
|
||||
|
||||
An unintended solution is called a *cook*, and a level with one doesn't teach what it was built to teach. [Module 13](../13-break-your-engine/lesson.md) explains how the solver works and what it can't tell you. Then design an eleventh level, and try to cook it before the tool does.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"objects": [
|
||||
{"kind": "text:FLAG", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:WIN", "x": 2, "y": 0},
|
||||
{"kind": "text:WALL", "x": 4, "y": 0},
|
||||
{"kind": "text:IS", "x": 5, "y": 0},
|
||||
{"kind": "text:STOP", "x": 6, "y": 0},
|
||||
{"kind": "text:HEDGE", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:STOP", "x": 2, "y": 1},
|
||||
{"kind": "wall", "x": 0, "y": 2},
|
||||
{"kind": "wall", "x": 1, "y": 2},
|
||||
{"kind": "wall", "x": 2, "y": 2},
|
||||
{"kind": "wall", "x": 3, "y": 2},
|
||||
{"kind": "wall", "x": 4, "y": 2},
|
||||
{"kind": "wall", "x": 5, "y": 2},
|
||||
{"kind": "wall", "x": 6, "y": 2},
|
||||
{"kind": "wall", "x": 5, "y": 3},
|
||||
{"kind": "flag", "x": 6, "y": 3},
|
||||
{"kind": "text:SHARD", "x": 0, "y": 4},
|
||||
{"kind": "text:IS", "x": 1, "y": 4},
|
||||
{"kind": "text:YOU", "x": 2, "y": 4},
|
||||
{"kind": "text:KNIGHT", "x": 0, "y": 5}
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Thorn Hedge", "x": 6, "y": 4},
|
||||
{"card": "Powder Keg", "x": 2, "y": 5},
|
||||
{"card": "Tin Knight", "x": 5, "y": 5},
|
||||
{"card": "Clay Shard", "x": 1, "y": 6}
|
||||
],
|
||||
"hand": ["Whirlwind"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"objects": [
|
||||
{"kind": "text:BABA", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:YOU", "x": 2, "y": 0},
|
||||
{"kind": "text:FLAG", "x": 4, "y": 0},
|
||||
{"kind": "text:IS", "x": 5, "y": 0},
|
||||
{"kind": "text:WIN", "x": 6, "y": 0},
|
||||
{"kind": "text:HEDGE", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:STOP", "x": 2, "y": 1},
|
||||
{"kind": "text:WALL", "x": 4, "y": 1},
|
||||
{"kind": "text:IS", "x": 5, "y": 1},
|
||||
{"kind": "text:STOP", "x": 6, "y": 1},
|
||||
{"kind": "wall", "x": 0, "y": 2},
|
||||
{"kind": "wall", "x": 1, "y": 2},
|
||||
{"kind": "wall", "x": 2, "y": 2},
|
||||
{"kind": "wall", "x": 3, "y": 2},
|
||||
{"kind": "wall", "x": 4, "y": 2},
|
||||
{"kind": "wall", "x": 5, "y": 2},
|
||||
{"kind": "wall", "x": 6, "y": 2},
|
||||
{"kind": "baba", "x": 2, "y": 3},
|
||||
{"kind": "wall", "x": 5, "y": 3},
|
||||
{"kind": "flag", "x": 6, "y": 3},
|
||||
{"kind": "text:DEATHRATTLE", "x": 0, "y": 4},
|
||||
{"kind": "text:IS", "x": 1, "y": 4},
|
||||
{"kind": "text:NOT", "x": 2, "y": 4},
|
||||
{"kind": "text:DOUBLE", "x": 3, "y": 4},
|
||||
{"kind": "text:IS", "x": 0, "y": 5},
|
||||
{"kind": "wall", "x": 6, "y": 5},
|
||||
{"kind": "text:DOUBLE", "x": 0, "y": 6},
|
||||
{"kind": "wall", "x": 6, "y": 6}
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Thorn Hedge", "x": 6, "y": 4},
|
||||
{"card": "Powder Keg", "x": 1, "y": 6}
|
||||
],
|
||||
"hand": ["Spark"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"objects": [
|
||||
{"kind": "text:BABA", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:YOU", "x": 2, "y": 0},
|
||||
{"kind": "text:WALL", "x": 4, "y": 0},
|
||||
{"kind": "text:IS", "x": 5, "y": 0},
|
||||
{"kind": "text:STOP", "x": 6, "y": 0},
|
||||
{"kind": "text:KEG", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:KEG", "x": 2, "y": 1},
|
||||
{"kind": "text:KEG", "x": 4, "y": 1},
|
||||
{"kind": "text:IS", "x": 5, "y": 1},
|
||||
{"kind": "text:FLAG", "x": 6, "y": 1},
|
||||
{"kind": "text:FLAG", "x": 0, "y": 2},
|
||||
{"kind": "text:IS", "x": 1, "y": 2},
|
||||
{"kind": "text:WIN", "x": 2, "y": 2},
|
||||
{"kind": "wall", "x": 0, "y": 3},
|
||||
{"kind": "wall", "x": 1, "y": 3},
|
||||
{"kind": "wall", "x": 2, "y": 3},
|
||||
{"kind": "wall", "x": 3, "y": 3},
|
||||
{"kind": "wall", "x": 4, "y": 3},
|
||||
{"kind": "wall", "x": 5, "y": 3},
|
||||
{"kind": "wall", "x": 6, "y": 3},
|
||||
{"kind": "text:KEG", "x": 0, "y": 4},
|
||||
{"kind": "text:IS", "x": 1, "y": 4},
|
||||
{"kind": "text:KEG", "x": 3, "y": 4},
|
||||
{"kind": "text:NOT", "x": 3, "y": 5},
|
||||
{"kind": "baba", "x": 5, "y": 5}
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Powder Keg", "x": 1, "y": 5}
|
||||
],
|
||||
"hand": []
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"objects": [
|
||||
{"kind": "text:KNIGHT", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:YOU", "x": 2, "y": 0},
|
||||
{"kind": "text:FLAG", "x": 4, "y": 0},
|
||||
{"kind": "text:IS", "x": 5, "y": 0},
|
||||
{"kind": "text:WIN", "x": 6, "y": 0},
|
||||
{"kind": "text:WALL", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:STOP", "x": 2, "y": 1},
|
||||
{"kind": "text:SHARD", "x": 4, "y": 1},
|
||||
{"kind": "text:IS", "x": 5, "y": 1},
|
||||
{"kind": "text:DEFEAT", "x": 6, "y": 1},
|
||||
{"kind": "text:KEG", "x": 0, "y": 2},
|
||||
{"kind": "text:IS", "x": 1, "y": 2},
|
||||
{"kind": "text:PUSH", "x": 2, "y": 2},
|
||||
{"kind": "text:AND", "x": 3, "y": 2},
|
||||
{"kind": "text:MELT", "x": 4, "y": 2},
|
||||
{"kind": "wall", "x": 0, "y": 3},
|
||||
{"kind": "wall", "x": 1, "y": 3},
|
||||
{"kind": "wall", "x": 2, "y": 3},
|
||||
{"kind": "wall", "x": 3, "y": 3},
|
||||
{"kind": "wall", "x": 4, "y": 3},
|
||||
{"kind": "wall", "x": 5, "y": 3},
|
||||
{"kind": "wall", "x": 6, "y": 3},
|
||||
{"kind": "text:LAVA", "x": 0, "y": 4},
|
||||
{"kind": "text:IS", "x": 1, "y": 4},
|
||||
{"kind": "text:HOT", "x": 2, "y": 5},
|
||||
{"kind": "wall", "x": 4, "y": 5},
|
||||
{"kind": "wall", "x": 5, "y": 5},
|
||||
{"kind": "wall", "x": 6, "y": 5},
|
||||
{"kind": "lava", "x": 4, "y": 6},
|
||||
{"kind": "flag", "x": 5, "y": 6},
|
||||
{"kind": "wall", "x": 6, "y": 6}
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Tin Knight", "x": 0, "y": 6},
|
||||
{"card": "Powder Keg", "x": 3, "y": 6},
|
||||
{"card": "Clay Shard", "x": 3, "y": 6}
|
||||
],
|
||||
"hand": []
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"objects": [
|
||||
{"kind": "text:BABA", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:YOU", "x": 2, "y": 0},
|
||||
{"kind": "text:FLAG", "x": 4, "y": 0},
|
||||
{"kind": "text:IS", "x": 5, "y": 0},
|
||||
{"kind": "text:WIN", "x": 6, "y": 0},
|
||||
{"kind": "text:WALL", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:STOP", "x": 2, "y": 1},
|
||||
{"kind": "text:SHARD", "x": 4, "y": 1},
|
||||
{"kind": "text:IS", "x": 5, "y": 1},
|
||||
{"kind": "text:PUSH", "x": 6, "y": 1},
|
||||
{"kind": "text:DOOR", "x": 0, "y": 2},
|
||||
{"kind": "text:IS", "x": 1, "y": 2},
|
||||
{"kind": "text:SHUT", "x": 2, "y": 2},
|
||||
{"kind": "text:AND", "x": 3, "y": 2},
|
||||
{"kind": "text:STOP", "x": 4, "y": 2},
|
||||
{"kind": "wall", "x": 0, "y": 3},
|
||||
{"kind": "wall", "x": 1, "y": 3},
|
||||
{"kind": "wall", "x": 2, "y": 3},
|
||||
{"kind": "wall", "x": 3, "y": 3},
|
||||
{"kind": "wall", "x": 4, "y": 3},
|
||||
{"kind": "wall", "x": 5, "y": 3},
|
||||
{"kind": "wall", "x": 6, "y": 3},
|
||||
{"kind": "text:SHARD", "x": 0, "y": 4},
|
||||
{"kind": "text:IS", "x": 1, "y": 4},
|
||||
{"kind": "wall", "x": 5, "y": 4},
|
||||
{"kind": "flag", "x": 6, "y": 4},
|
||||
{"kind": "text:OPEN", "x": 2, "y": 5},
|
||||
{"kind": "door", "x": 6, "y": 5},
|
||||
{"kind": "baba", "x": 1, "y": 6},
|
||||
{"kind": "wall", "x": 5, "y": 6},
|
||||
{"kind": "wall", "x": 6, "y": 6}
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Clay Golem", "x": 4, "y": 5}
|
||||
],
|
||||
"hand": ["Spark", "Spark"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"width": 5,
|
||||
"height": 5,
|
||||
"objects": [
|
||||
{"kind": "text:BABA", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:YOU", "x": 2, "y": 0},
|
||||
{"kind": "text:WALL", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:STOP", "x": 2, "y": 1},
|
||||
{"kind": "wall", "x": 0, "y": 2},
|
||||
{"kind": "wall", "x": 1, "y": 2},
|
||||
{"kind": "wall", "x": 2, "y": 2},
|
||||
{"kind": "wall", "x": 3, "y": 2},
|
||||
{"kind": "wall", "x": 4, "y": 2},
|
||||
{"kind": "text:SHARD", "x": 0, "y": 3},
|
||||
{"kind": "text:IS", "x": 1, "y": 3},
|
||||
{"kind": "text:WIN", "x": 3, "y": 3},
|
||||
{"kind": "baba", "x": 4, "y": 3}
|
||||
],
|
||||
"cards": [],
|
||||
"hand": ["Graft"]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"objects": [
|
||||
{"kind": "text:BABA", "x": 0, "y": 0},
|
||||
{"kind": "text:IS", "x": 1, "y": 0},
|
||||
{"kind": "text:YOU", "x": 2, "y": 0},
|
||||
{"kind": "text:WALL", "x": 4, "y": 0},
|
||||
{"kind": "text:IS", "x": 5, "y": 0},
|
||||
{"kind": "text:STOP", "x": 6, "y": 0},
|
||||
{"kind": "text:ROCK", "x": 0, "y": 1},
|
||||
{"kind": "text:IS", "x": 1, "y": 1},
|
||||
{"kind": "text:PUSH", "x": 2, "y": 1},
|
||||
{"kind": "text:FLAG", "x": 4, "y": 1},
|
||||
{"kind": "text:IS", "x": 5, "y": 1},
|
||||
{"kind": "text:WIN", "x": 6, "y": 1},
|
||||
{"kind": "text:SKULL", "x": 0, "y": 2},
|
||||
{"kind": "text:IS", "x": 1, "y": 2},
|
||||
{"kind": "text:DEFEAT", "x": 2, "y": 2},
|
||||
{"kind": "wall", "x": 0, "y": 3},
|
||||
{"kind": "wall", "x": 1, "y": 3},
|
||||
{"kind": "wall", "x": 2, "y": 3},
|
||||
{"kind": "wall", "x": 3, "y": 3},
|
||||
{"kind": "wall", "x": 4, "y": 3},
|
||||
{"kind": "wall", "x": 5, "y": 3},
|
||||
{"kind": "wall", "x": 6, "y": 3},
|
||||
{"kind": "text:SKULL", "x": 0, "y": 4},
|
||||
{"kind": "wall", "x": 2, "y": 4},
|
||||
{"kind": "wall", "x": 3, "y": 4},
|
||||
{"kind": "skull", "x": 4, "y": 4},
|
||||
{"kind": "skull", "x": 5, "y": 4},
|
||||
{"kind": "wall", "x": 6, "y": 4},
|
||||
{"kind": "text:IS", "x": 0, "y": 5},
|
||||
{"kind": "baba", "x": 1, "y": 5},
|
||||
{"kind": "rock", "x": 2, "y": 5},
|
||||
{"kind": "rock", "x": 3, "y": 5},
|
||||
{"kind": "skull", "x": 4, "y": 5},
|
||||
{"kind": "skull", "x": 5, "y": 5},
|
||||
{"kind": "flag", "x": 6, "y": 5},
|
||||
{"kind": "text:SINK", "x": 1, "y": 6},
|
||||
{"kind": "wall", "x": 3, "y": 6},
|
||||
{"kind": "skull", "x": 4, "y": 6},
|
||||
{"kind": "skull", "x": 5, "y": 6},
|
||||
{"kind": "wall", "x": 6, "y": 6}
|
||||
],
|
||||
"cards": [],
|
||||
"hand": []
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{ "level": "l1-double-tap", "shortest": 10, "neverRule": "DEATHRATTLE IS DOUBLE" },
|
||||
{ "level": "l2-spliced-corpse", "shortest": 4 },
|
||||
{ "level": "l3-simultaneous-paradox", "shortest": 6, "naiveUnsolvable": true },
|
||||
{ "level": "l4-crossed-out", "shortest": 7, "neverRule": "DEATHRATTLE IS DOUBLE" },
|
||||
{ "level": "l5-sealed-keg", "shortest": 6, "neverRule": "KEG IS NOT KEG" },
|
||||
{ "level": "l6-melt-before-defeat", "shortest": 7, "neverRule": "LAVA IS HOT" },
|
||||
{ "level": "l7-clay-key", "shortest": 9, "neverRule": "SHARD IS OPEN" },
|
||||
{ "level": "l8-nothing-dies", "shortest": 4, "neverRule": "SHARD IS WIN" },
|
||||
{ "level": "l9-skull-river", "shortest": 9, "neverRule": "SKULL IS SINK" },
|
||||
{ "level": "l10-friendly-fire", "shortest": 6, "neverRule": "KNIGHT IS YOU" }
|
||||
]
|
||||
@@ -4,7 +4,7 @@ Each heading is a test name. The Rust test lives in `tests/rust/test_10.rs` and
|
||||
|
||||
SpellGrid is the course's own small game: Part I's card engine and Part II's grid engine in one level. The harness adds one constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`:
|
||||
|
||||
- Every Part II function works as in Modules 6–9: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost`.
|
||||
- Every Part II function works as in Modules 6–9: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `stackAt`, `step`, `won`, `lost`.
|
||||
- The level format gains two optional fields (`course/schema/level.schema.json`): `cards`, a list of `{ card, x, y }`, and `hand`, a list of card names. Each entry of `cards` is a minion in play for player 0 **and** a grid object at (x, y) whose noun is the card's `"noun"` field (`"noun": "KEG"` makes an object of kind `"keg"`). Each name in `hand` is a card in player 0's hand.
|
||||
- `cards` (the constructor's second argument) is a list of extra card definitions, added to the course's cards. Every test here passes an empty list.
|
||||
- One id space: a minion's id is its grid object's id. `place`, `posOf`, `hasProperty` and `health` all take it, and `log()` reports it. A hero, or a card that isn't on the grid, gets an id no grid object has.
|
||||
@@ -168,3 +168,91 @@ The same level and the same inputs, on a SpellGrid with `withNaiveAuraTiming()`
|
||||
- Assertions:
|
||||
- The keg's Deathrattle triggered twice: the Warden's aura was still in the snapshot when the Death Phase began.
|
||||
- `posOf(knight)` is `null`, `lost()` is true and `won()` is false: the second Deathrattle killed the knight along with the hedge. This is the trap the level is built on.
|
||||
|
||||
### test_10_level_4_solution
|
||||
|
||||
Level `levels/l4-crossed-out.json`. The Thorn Hedge (2 Health) is `STOP` and blocks the flag; the hand holds one Spark. `DEATHRATTLE IS DOUBLE` is written down a column and `DEATHRATTLE IS NOT DOUBLE` along a row, sharing the `DEATHRATTLE`. The keg is at (1,6).
|
||||
|
||||
- Assertions 1: `rules()` contains `"DEATHRATTLE IS DOUBLE"`, and `activeRules()` doesn't: the negated rule cancels it.
|
||||
- Inputs: `step("right")`, `step("down")`, which pushes the row's `DOUBLE` out of line.
|
||||
- Assertions 2: `rules()` no longer contains `"DEATHRATTLE IS NOT DOUBLE"`, and `activeRules()` contains `"DEATHRATTLE IS DOUBLE"`.
|
||||
- Inputs: `step("right")`, `step("right")`, then `cast("Spark", keg)`.
|
||||
- Assertions 3: the keg's Deathrattle has triggered twice, and `posOf(hedge)` is `null`.
|
||||
- Inputs: `step("right")`, `step("up")`.
|
||||
- Assertions 4: `won()` is true.
|
||||
|
||||
### test_10_level_5_solution
|
||||
|
||||
Level `levels/l5-sealed-keg.json`. Walled off at the top: `KEG IS KEG`, `KEG IS FLAG`, `FLAG IS WIN`. There is no flag. Below: `KEG IS` with a gap and then `KEG`, a loose `NOT`, and the keg at (1,5).
|
||||
|
||||
- Inputs: `step("left")`, `step("left")`, `step("down")`, `step("left")`.
|
||||
- Assertions 1: `kindsAt(1,5)` is `["keg"]`: the lock holds.
|
||||
- Input: `step("up")`, which pushes `NOT` into the gap.
|
||||
- Assertions 2:
|
||||
- `activeRules()` contains `"KEG IS NOT KEG"` and not `"KEG IS KEG"`.
|
||||
- The keg's Deathrattle has triggered once, `posOf(keg)` is `null`, and `kindsAt(1,5)` is `["flag"]`: the keg was deleted, and because the negation also cancelled the lock, `KEG IS FLAG` still made its flag.
|
||||
- Input: `step("left")`.
|
||||
- Assertions 3: `won()` is true.
|
||||
|
||||
### test_10_level_6_solution
|
||||
|
||||
Level `levels/l6-melt-before-defeat.json`. The player is a Tin Knight (`KNIGHT IS YOU`, 3 Health) at (0,6). A Powder Keg (`PUSH` and `MELT`) stands on a Clay Shard (`SHARD IS DEFEAT`) at (3,6), with lava and then the flag beyond. `LAVA IS` is missing its `HOT`.
|
||||
|
||||
- Assertions 1: `stackAt(3,6)` is `["keg", "shard"]`.
|
||||
- Inputs: `step("right")`, `step("right")`, `step("up")` (finishing `LAVA IS HOT`), `step("down")`.
|
||||
- Assertions 2: `activeRules()` contains `"LAVA IS HOT"`.
|
||||
- Input: `step("right")`: the knight pushes the keg into the lava and steps onto the shard's tile.
|
||||
- Assertions 3:
|
||||
- The keg's Deathrattle triggered once, and `posOf(keg)` is `null`.
|
||||
- `posOf(knight)` is `[3, 6]`, `kindsAt(3,6)` is `["knight"]`, `health(knight)` is 2 and `lost()` is false. HOT/MELT runs before DEFEAT: the keg melts, its Deathrattle kills the shard, and when DEFEAT runs there is nothing `DEFEAT` on the knight's tile.
|
||||
- Inputs: `step("right")`, `step("right")`.
|
||||
- Assertions 4: `won()` is true.
|
||||
|
||||
### test_10_level_7_solution
|
||||
|
||||
Level `levels/l7-clay-key.json`. The flag is behind a door (`DOOR IS SHUT AND STOP`). `SHARD IS PUSH` is written, and `SHARD IS` waits for its `OPEN`. There is no shard: a Clay Golem stands at (4,5), and the hand holds two Sparks.
|
||||
|
||||
- Inputs: `step("right")`, `step("up")` (finishing `SHARD IS OPEN`), `step("right")`.
|
||||
- Assertions 1: `activeRules()` contains `"SHARD IS OPEN"`.
|
||||
- Inputs: `cast("Spark", golem)` twice.
|
||||
- Assertions 2: `posOf(golem)` is `null`, `kindsAt(4,5)` is `["shard"]`, and the minion there is a `"Clay Shard"`: seam 4 put the Deathrattle's token where the golem died.
|
||||
- Inputs: `step("right")`, `step("right")`, pushing the shard into the door.
|
||||
- Assertions 3: `kindsAt(6,5)` is `[]`: the shard and the door destroyed each other.
|
||||
- Inputs: `step("right")`, `step("up")`.
|
||||
- Assertions 4: `won()` is true.
|
||||
|
||||
### test_10_level_8_solution
|
||||
|
||||
Level `levels/l8-nothing-dies.json`. `SHARD IS` waits for its `WIN`, and there is no shard. The hand holds one Graft, and nothing on the level can deal damage.
|
||||
|
||||
- Setup: `setChooser` answering `"Clay Golem"` for the base, `"Novice Engineer"` for the donor and `"top"` for the half.
|
||||
- Input: `step("left")`.
|
||||
- Assertions 1: `activeRules()` contains `"SHARD IS WIN"`.
|
||||
- Inputs: `cast("Graft")`, `clearLog()`, then `play("Clay Golem (Assembled)", 4, 3)`, returning `golem`.
|
||||
- Assertions 2: the log contains `Triggered{source: golem, trigger: "battlecry"}`, `posOf(golem)` is `[4, 3]` and `stackAt(4,3)` is `["golem", "shard"]`. The top half swap made "Deathrattle: Summon a Clay Shard." into "Battlecry: Summon a Clay Shard.", and seam 4 puts the shard on the living golem's tile.
|
||||
- Input: `step("right")`.
|
||||
- Assertions 3: `won()` is true.
|
||||
|
||||
### test_10_level_9_solution
|
||||
|
||||
Level `levels/l9-skull-river.json`. A river of skulls (`SKULL IS DEFEAT`) two tiles wide lies between Baba and the flag. Two rocks (`ROCK IS PUSH`) stand in a corridor in a line, and `SKULL IS` waits for its `SINK`.
|
||||
|
||||
- Input: `step("right")`: Baba pushes both rocks at once.
|
||||
- Assertions 1: `kindsAt(4,5)` is `["rock", "skull"]` and `kindsAt(3,5)` is `["rock"]`.
|
||||
- Inputs: `step("down")`, `step("left")` (finishing `SKULL IS SINK`).
|
||||
- Assertions 2: `activeRules()` contains `"SKULL IS SINK"`, and `kindsAt(4,5)` is `[]`: the skull took the rock with it at the end of that turn.
|
||||
- Inputs: `step("up")`, `step("right")`, `step("right")`, `step("right")`.
|
||||
- Assertions 3: `kindsAt(5,5)` is `[]`: the second rock sank in the second skull.
|
||||
- Inputs: `step("right")`, `step("right")`.
|
||||
- Assertions 4: `won()` is true.
|
||||
|
||||
### test_10_level_10_solution
|
||||
|
||||
Level `levels/l10-friendly-fire.json`. The player is a Clay Shard (`SHARD IS YOU`, 1 Health) at (1,6). The Thorn Hedge (2 Health) is `STOP` and blocks the flag; a Powder Keg and a Tin Knight (3 Health) are in play, and the hand holds Whirlwind. A `KNIGHT` text lies below `SHARD`.
|
||||
|
||||
- Inputs: `step("left")`, `step("up")`, which pushes `KNIGHT` into the sentence and `SHARD` out of it.
|
||||
- Assertions 1: `activeRules()` contains `"KNIGHT IS YOU"` and not `"SHARD IS YOU"`.
|
||||
- Inputs: `step("up")`, then `cast("Whirlwind")`.
|
||||
- Assertions 2: `posOf(shard)` and `posOf(hedge)` are `null`, `health(knight)` is 1, and `lost()` is false. Whirlwind and the keg's Deathrattle deal 2 damage to every minion: the shard dies, and so would the player if it were still the shard.
|
||||
- Inputs: `step("right")`, `step("up")`.
|
||||
- Assertions 3: `won()` is true.
|
||||
@@ -57,6 +57,13 @@ fn has(rules: &[String], rule: &str) -> bool {
|
||||
rules.iter().any(|r| r == rule)
|
||||
}
|
||||
|
||||
/// Play one turn per direction, in order.
|
||||
fn steps(g: &mut SpellGrid, dirs: &[Dir]) {
|
||||
for &dir in dirs {
|
||||
g.step(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_spatial_rule_feeds_interceptor() {
|
||||
let mut g = new_spell_grid(
|
||||
@@ -473,3 +480,208 @@ fn test_10_level_3_naive_would_differ() {
|
||||
assert!(g.lost());
|
||||
assert!(!g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_4_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l4-crossed-out.json")),
|
||||
vec![],
|
||||
);
|
||||
let keg = minion(&g, 1, 6);
|
||||
let hedge = minion(&g, 6, 4);
|
||||
|
||||
assert!(has(&g.rules(), "DEATHRATTLE IS DOUBLE"));
|
||||
assert!(!has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Down]);
|
||||
|
||||
assert!(!has(&g.rules(), "DEATHRATTLE IS NOT DOUBLE"));
|
||||
assert!(has(&g.active_rules(), "DEATHRATTLE IS DOUBLE"));
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Right]);
|
||||
g.cast("Spark", Some(keg));
|
||||
|
||||
assert_eq!(deathrattles(&g, keg), 2);
|
||||
assert_eq!(g.pos_of(hedge), None);
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Up]);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_5_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l5-sealed-keg.json")),
|
||||
vec![],
|
||||
);
|
||||
let keg = minion(&g, 1, 5);
|
||||
|
||||
steps(&mut g, &[Dir::Left, Dir::Left, Dir::Down, Dir::Left]);
|
||||
|
||||
assert_eq!(g.kinds_at(1, 5), vec!["keg"]);
|
||||
|
||||
g.step(Dir::Up);
|
||||
|
||||
assert!(has(&g.active_rules(), "KEG IS NOT KEG"));
|
||||
assert!(!has(&g.active_rules(), "KEG IS KEG"));
|
||||
assert_eq!(deathrattles(&g, keg), 1);
|
||||
assert_eq!(g.pos_of(keg), None);
|
||||
assert_eq!(g.kinds_at(1, 5), vec!["flag"]);
|
||||
|
||||
g.step(Dir::Left);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_6_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l6-melt-before-defeat.json")),
|
||||
vec![],
|
||||
);
|
||||
let knight = minion(&g, 0, 6);
|
||||
let keg = minion(&g, 3, 6);
|
||||
|
||||
assert_eq!(g.stack_at(3, 6), vec!["keg", "shard"]);
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Right, Dir::Up, Dir::Down]);
|
||||
|
||||
assert!(has(&g.active_rules(), "LAVA IS HOT"));
|
||||
|
||||
g.step(Dir::Right);
|
||||
|
||||
// The knight stepped onto the DEFEAT shard as the keg went into the lava. HOT/MELT runs
|
||||
// first: the keg's Deathrattle kills the shard, and DEFEAT finds nothing on the knight's tile.
|
||||
assert_eq!(deathrattles(&g, keg), 1);
|
||||
assert_eq!(g.pos_of(keg), None);
|
||||
assert_eq!(g.pos_of(knight), Some((3, 6)));
|
||||
assert_eq!(g.kinds_at(3, 6), vec!["knight"]);
|
||||
assert_eq!(g.health(knight), 2);
|
||||
assert!(!g.lost());
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Right]);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_7_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l7-clay-key.json")),
|
||||
vec![],
|
||||
);
|
||||
let golem = minion(&g, 4, 5);
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Up, Dir::Right]);
|
||||
|
||||
assert!(has(&g.active_rules(), "SHARD IS OPEN"));
|
||||
|
||||
g.cast("Spark", Some(golem));
|
||||
g.cast("Spark", Some(golem));
|
||||
|
||||
assert_eq!(g.pos_of(golem), None);
|
||||
assert_eq!(g.kinds_at(4, 5), vec!["shard"]);
|
||||
assert_eq!(g.name_of(minion(&g, 4, 5)), "Clay Shard");
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Right]);
|
||||
|
||||
assert_eq!(g.kinds_at(6, 5), Vec::<String>::new());
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Up]);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_8_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l8-nothing-dies.json")),
|
||||
vec![],
|
||||
);
|
||||
g.set_chooser(|kind, _| {
|
||||
match kind {
|
||||
"base" => "Clay Golem",
|
||||
"donor" => "Novice Engineer",
|
||||
_ => "top",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
|
||||
g.step(Dir::Left);
|
||||
|
||||
assert!(has(&g.active_rules(), "SHARD IS WIN"));
|
||||
|
||||
g.cast("Graft", None);
|
||||
g.clear_log();
|
||||
let golem = g.play("Clay Golem (Assembled)", 4, 3);
|
||||
|
||||
// Battlecry, not Deathrattle: the golem is alive and its shard already stands on its tile.
|
||||
assert!(g.log().contains(&GameEvent::Triggered {
|
||||
source: golem,
|
||||
trigger: "battlecry".to_string(),
|
||||
}));
|
||||
assert_eq!(g.pos_of(golem), Some((4, 3)));
|
||||
assert_eq!(g.stack_at(4, 3), vec!["golem", "shard"]);
|
||||
|
||||
g.step(Dir::Right);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_9_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l9-skull-river.json")),
|
||||
vec![],
|
||||
);
|
||||
|
||||
g.step(Dir::Right);
|
||||
|
||||
assert_eq!(g.kinds_at(4, 5), vec!["rock", "skull"]);
|
||||
assert_eq!(g.kinds_at(3, 5), vec!["rock"]);
|
||||
|
||||
steps(&mut g, &[Dir::Down, Dir::Left]);
|
||||
|
||||
// SKULL IS SINK: at the end of the turn the skull under the rock takes the rock with it.
|
||||
assert!(has(&g.active_rules(), "SKULL IS SINK"));
|
||||
assert_eq!(g.kinds_at(4, 5), Vec::<String>::new());
|
||||
|
||||
steps(&mut g, &[Dir::Up, Dir::Right, Dir::Right, Dir::Right]);
|
||||
|
||||
assert_eq!(g.kinds_at(5, 5), Vec::<String>::new());
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Right]);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_10_level_10_solution() {
|
||||
let mut g = new_spell_grid(
|
||||
level_json(include_str!("../levels/l10-friendly-fire.json")),
|
||||
vec![],
|
||||
);
|
||||
let shard = minion(&g, 1, 6);
|
||||
let knight = minion(&g, 5, 5);
|
||||
let hedge = minion(&g, 6, 4);
|
||||
|
||||
steps(&mut g, &[Dir::Left, Dir::Up]);
|
||||
|
||||
assert!(has(&g.active_rules(), "KNIGHT IS YOU"));
|
||||
assert!(!has(&g.active_rules(), "SHARD IS YOU"));
|
||||
|
||||
g.step(Dir::Up);
|
||||
g.cast("Whirlwind", None);
|
||||
|
||||
// Whirlwind and the keg's Deathrattle: 2 damage to everything. The shard you were dies;
|
||||
// the knight you are now is left on 1.
|
||||
assert_eq!(g.pos_of(shard), None);
|
||||
assert_eq!(g.pos_of(hedge), None);
|
||||
assert_eq!(g.health(knight), 1);
|
||||
assert!(!g.lost());
|
||||
|
||||
steps(&mut g, &[Dir::Right, Dir::Up]);
|
||||
|
||||
assert!(g.won());
|
||||
}
|
||||
@@ -4,6 +4,13 @@ import { newSpellGrid, type Id, type LevelDef, type SpellGrid } from "../src/har
|
||||
import l1 from "../levels/l1-double-tap.json";
|
||||
import l2 from "../levels/l2-spliced-corpse.json";
|
||||
import l3 from "../levels/l3-simultaneous-paradox.json";
|
||||
import l4 from "../levels/l4-crossed-out.json";
|
||||
import l5 from "../levels/l5-sealed-keg.json";
|
||||
import l6 from "../levels/l6-melt-before-defeat.json";
|
||||
import l7 from "../levels/l7-clay-key.json";
|
||||
import l8 from "../levels/l8-nothing-dies.json";
|
||||
import l9 from "../levels/l9-skull-river.json";
|
||||
import l10 from "../levels/l10-friendly-fire.json";
|
||||
|
||||
type Placed = [string, number, number];
|
||||
|
||||
@@ -36,6 +43,11 @@ function minion(g: SpellGrid, x: number, y: number): Id {
|
||||
const deathrattles = (g: SpellGrid, source: Id): number =>
|
||||
g.log().filter((e) => e.type === "Triggered" && e.source === source && e.trigger === "deathrattle").length;
|
||||
|
||||
/** Play one turn per direction, in order. */
|
||||
function steps(g: SpellGrid, ...dirs: ("up" | "down" | "left" | "right")[]): void {
|
||||
for (const dir of dirs) g.step(dir);
|
||||
}
|
||||
|
||||
test("test_10_spatial_rule_feeds_interceptor", () => {
|
||||
const g = newSpellGrid(
|
||||
level(6, 3, {
|
||||
@@ -366,3 +378,170 @@ test("test_10_level_3_naive_would_differ", () => {
|
||||
expect(g.lost()).toBe(true);
|
||||
expect(g.won()).toBe(false);
|
||||
});
|
||||
|
||||
test("test_10_level_4_solution", () => {
|
||||
const g = newSpellGrid(l4 as LevelDef, []);
|
||||
const keg = minion(g, 1, 6);
|
||||
const hedge = minion(g, 6, 4);
|
||||
|
||||
expect(g.rules()).toContain("DEATHRATTLE IS DOUBLE");
|
||||
expect(g.activeRules()).not.toContain("DEATHRATTLE IS DOUBLE");
|
||||
|
||||
steps(g, "right", "down");
|
||||
|
||||
expect(g.rules()).not.toContain("DEATHRATTLE IS NOT DOUBLE");
|
||||
expect(g.activeRules()).toContain("DEATHRATTLE IS DOUBLE");
|
||||
|
||||
steps(g, "right", "right");
|
||||
g.cast("Spark", keg);
|
||||
|
||||
expect(deathrattles(g, keg)).toBe(2);
|
||||
expect(g.posOf(hedge)).toBeNull();
|
||||
|
||||
steps(g, "right", "up");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
|
||||
test("test_10_level_5_solution", () => {
|
||||
const g = newSpellGrid(l5 as LevelDef, []);
|
||||
const keg = minion(g, 1, 5);
|
||||
|
||||
steps(g, "left", "left", "down", "left");
|
||||
|
||||
expect(g.kindsAt(1, 5)).toEqual(["keg"]);
|
||||
|
||||
g.step("up");
|
||||
|
||||
expect(g.activeRules()).toContain("KEG IS NOT KEG");
|
||||
expect(g.activeRules()).not.toContain("KEG IS KEG");
|
||||
expect(deathrattles(g, keg)).toBe(1);
|
||||
expect(g.posOf(keg)).toBeNull();
|
||||
expect(g.kindsAt(1, 5)).toEqual(["flag"]);
|
||||
|
||||
g.step("left");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
|
||||
test("test_10_level_6_solution", () => {
|
||||
const g = newSpellGrid(l6 as LevelDef, []);
|
||||
const knight = minion(g, 0, 6);
|
||||
const keg = minion(g, 3, 6);
|
||||
|
||||
expect(g.stackAt(3, 6)).toEqual(["keg", "shard"]);
|
||||
|
||||
steps(g, "right", "right", "up", "down");
|
||||
|
||||
expect(g.activeRules()).toContain("LAVA IS HOT");
|
||||
|
||||
g.step("right");
|
||||
|
||||
// The knight stepped onto the DEFEAT shard as the keg went into the lava. HOT/MELT runs
|
||||
// first: the keg's Deathrattle kills the shard, and DEFEAT finds nothing on the knight's tile.
|
||||
expect(deathrattles(g, keg)).toBe(1);
|
||||
expect(g.posOf(keg)).toBeNull();
|
||||
expect(g.posOf(knight)).toEqual([3, 6]);
|
||||
expect(g.kindsAt(3, 6)).toEqual(["knight"]);
|
||||
expect(g.health(knight)).toBe(2);
|
||||
expect(g.lost()).toBe(false);
|
||||
|
||||
steps(g, "right", "right");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
|
||||
test("test_10_level_7_solution", () => {
|
||||
const g = newSpellGrid(l7 as LevelDef, []);
|
||||
const golem = minion(g, 4, 5);
|
||||
|
||||
steps(g, "right", "up", "right");
|
||||
|
||||
expect(g.activeRules()).toContain("SHARD IS OPEN");
|
||||
|
||||
g.cast("Spark", golem);
|
||||
g.cast("Spark", golem);
|
||||
|
||||
expect(g.posOf(golem)).toBeNull();
|
||||
expect(g.kindsAt(4, 5)).toEqual(["shard"]);
|
||||
expect(g.nameOf(minion(g, 4, 5))).toBe("Clay Shard");
|
||||
|
||||
steps(g, "right", "right");
|
||||
|
||||
expect(g.kindsAt(6, 5)).toEqual([]);
|
||||
|
||||
steps(g, "right", "up");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
|
||||
test("test_10_level_8_solution", () => {
|
||||
const g = newSpellGrid(l8 as LevelDef, []);
|
||||
g.setChooser((kind) => (kind === "base" ? "Clay Golem" : kind === "donor" ? "Novice Engineer" : "top"));
|
||||
|
||||
g.step("left");
|
||||
|
||||
expect(g.activeRules()).toContain("SHARD IS WIN");
|
||||
|
||||
g.cast("Graft");
|
||||
g.clearLog();
|
||||
const golem = g.play("Clay Golem (Assembled)", 4, 3);
|
||||
|
||||
// Battlecry, not Deathrattle: the golem is alive and its shard already stands on its tile.
|
||||
expect(g.log()).toContainEqual({ type: "Triggered", source: golem, trigger: "battlecry" });
|
||||
expect(g.posOf(golem)).toEqual([4, 3]);
|
||||
expect(g.stackAt(4, 3)).toEqual(["golem", "shard"]);
|
||||
|
||||
g.step("right");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
|
||||
test("test_10_level_9_solution", () => {
|
||||
const g = newSpellGrid(l9 as LevelDef, []);
|
||||
|
||||
g.step("right");
|
||||
|
||||
expect(g.kindsAt(4, 5)).toEqual(["rock", "skull"]);
|
||||
expect(g.kindsAt(3, 5)).toEqual(["rock"]);
|
||||
|
||||
steps(g, "down", "left");
|
||||
|
||||
// SKULL IS SINK: at the end of the turn the skull under the rock takes the rock with it.
|
||||
expect(g.activeRules()).toContain("SKULL IS SINK");
|
||||
expect(g.kindsAt(4, 5)).toEqual([]);
|
||||
|
||||
steps(g, "up", "right", "right", "right");
|
||||
|
||||
expect(g.kindsAt(5, 5)).toEqual([]);
|
||||
|
||||
steps(g, "right", "right");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
|
||||
test("test_10_level_10_solution", () => {
|
||||
const g = newSpellGrid(l10 as LevelDef, []);
|
||||
const shard = minion(g, 1, 6);
|
||||
const knight = minion(g, 5, 5);
|
||||
const hedge = minion(g, 6, 4);
|
||||
|
||||
steps(g, "left", "up");
|
||||
|
||||
expect(g.activeRules()).toContain("KNIGHT IS YOU");
|
||||
expect(g.activeRules()).not.toContain("SHARD IS YOU");
|
||||
|
||||
g.step("up");
|
||||
g.cast("Whirlwind");
|
||||
|
||||
// Whirlwind and the keg's Deathrattle: 2 damage to everything. The shard you were dies;
|
||||
// the knight you are now is left on 1.
|
||||
expect(g.posOf(shard)).toBeNull();
|
||||
expect(g.posOf(hedge)).toBeNull();
|
||||
expect(g.health(knight)).toBe(1);
|
||||
expect(g.lost()).toBe(false);
|
||||
|
||||
steps(g, "right", "up");
|
||||
|
||||
expect(g.won()).toBe(true);
|
||||
});
|
||||
@@ -111,7 +111,7 @@ An engine that is deterministic gets a second kind of undo for nothing: record t
|
||||
|
||||
## Solving your own levels
|
||||
|
||||
A SpellGrid level has a few inputs per position (four directions, plus a card or two), no hidden information once the chooser's answers are enumerated, and no randomness. So the positions reachable from the start form a graph that can be explored exhaustively. The course's solver is about 150 lines, in `tools/solver/solve.ts`:
|
||||
A SpellGrid level has a few inputs per position (four directions, plus a card or two), no hidden information once the chooser's answers are enumerated, and no randomness. So the positions reachable from the start form a graph that can be explored exhaustively. The course's solver is about 160 lines, in `tools/solver/solve.ts`:
|
||||
|
||||
```sh
|
||||
node tools/solve-spellgrid.mjs l3 # shortest solution
|
||||
@@ -124,14 +124,14 @@ How it works:
|
||||
|
||||
- **Breadth-first over inputs**, so the first solution found is a shortest one.
|
||||
- **Replay, not copy.** Each position is reached by starting a fresh level and applying its inputs. The solver never clones engine state, so it needs nothing from the engine except determinism and the harness you already wrote. It is slow per position and it cannot be wrong about what a position is.
|
||||
- **A key for "the same position".** Two input sequences that lead to the same tiles, minions, Health, hand and hero Health are one position, and only the first is explored. This is the delicate part, for the reason the last section gave: the key has to include everything that can change the future. The solver adds the Assemble choices made so far, because two Assembled cards can share a name and differ in text. It does not include object age, so in a level where age breaks a tie it could merge two positions that differ. The three shipped levels have no such tie. Yours might.
|
||||
- **A key for "the same position".** Two input sequences that lead to the same tiles, minions, Health, hand and hero Health are one position, and only the first is explored. This is the delicate part, for the reason the last section gave: the key has to include everything that can change the future. The solver adds the Assemble choices made so far, because two Assembled cards can share a name and differ in text. Each tile's objects go into the key oldest first (`stackAt`, from Module 8), because age breaks ties: which `YOU` object moves first, which `SHUT` partner an `OPEN` key takes. The key still doesn't record age between objects on different tiles, or the order in which minions were played, so in a level where one of those breaks a tie it could merge two positions that differ. Keep that in mind when your level has two `YOU` objects, or two minions that die together.
|
||||
- **A filter.** `--never-rule` discards positions in which a given rule is in force. That turns "is the level solvable?" into "is it solvable *without the idea*?", which is the question a designer cares about.
|
||||
|
||||
What it found for the shipped levels is in [Module 10](../10-spellgrid/lesson.md)'s Stress it: each is solvable in the scripted number of inputs, Level 1 has no solution that avoids `DEATHRATTLE IS DOUBLE`, and Level 3 has no solution at all on the naive engine. Those are stronger statements than any test in Module 10 makes, and they hold for every line of play, not for one script.
|
||||
What it found for the shipped levels is in [Module 10](../10-spellgrid/lesson.md)'s Stress it: each is solvable in the scripted number of inputs, Level 1 has no solution that avoids `DEATHRATTLE IS DOUBLE`, and Level 3 has no solution at all on the naive engine. Those are stronger statements than any test in Module 10 makes, and they hold for every line of play, not for one script. Levels 4 to 10 were designed with the solver as the adversary, each around one idea that it proves the level can't do without. `course/10-spellgrid/levels/proofs.json` writes down all ten claims, and `node tools/verify-levels.mjs` checks them again: run it after any change to the engine or the levels.
|
||||
|
||||
An unintended solution is called a *cook*. Puzzle designers have always hunted cooks by hand; a solver hunts them while you sleep. The method generalises: state what the level is *for* as a predicate on positions, forbid it, and search.
|
||||
|
||||
**Try it.** Write a fourth SpellGrid level around one idea from the course (say, a `NOT` that has to be broken before a Deathrattle can double). Run the solver with `--all` and read every solution it prints. Then run it with the idea forbidden.
|
||||
**Try it.** Write an eleventh SpellGrid level around an idea from the course that levels 4 to 10 don't use (a Clay Golem whose shard is `YOU`, say). Run the solver with `--all` and read every solution it prints. Then run it with the idea forbidden, and give it an entry in `proofs.json`.
|
||||
|
||||
## What search can't tell you
|
||||
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@
|
||||
"check:links": "node tools/check-links.mjs",
|
||||
"play:spellgrid": "node tools/play-spellgrid.mjs",
|
||||
"solve:spellgrid": "node tools/solve-spellgrid.mjs",
|
||||
"diff:tracks": "node tools/diff-tracks.mjs"
|
||||
"diff:tracks": "node tools/diff-tracks.mjs",
|
||||
"verify:levels": "node tools/verify-levels.mjs"
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,18 @@ export function parseArgs(argv) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** Is `file`, in a levels/ directory, a level? (`proofs.json` beside them is verify-levels' data.) */
|
||||
export const isLevelFile = (file) => file.endsWith(".json") && file !== "proofs.json";
|
||||
|
||||
/** The names of the levels in `levelsDir`, in number order (l2 before l10). */
|
||||
export function levelNames(levelsDir) {
|
||||
return existsSync(levelsDir) ? readdirSync(levelsDir).filter(isLevelFile).map((f) => basename(f, ".json")).sort((a, b) => a.localeCompare(b, "en", { numeric: true })) : [];
|
||||
}
|
||||
|
||||
/** The level file to play: a path that exists, else a level of the module by name or prefix. */
|
||||
export function resolveLevel(levelsDir, level) {
|
||||
if (existsSync(level) && level.endsWith(".json")) return { path: resolve(level), staged: false };
|
||||
const files = existsSync(levelsDir) ? readdirSync(levelsDir).filter((f) => f.endsWith(".json")).sort() : [];
|
||||
const files = levelNames(levelsDir).map((name) => `${name}.json`);
|
||||
const match = files.find((f) => f === level || f === `${level}.json`) ??
|
||||
files.find((f) => f.startsWith(`${level}-`));
|
||||
if (!match) return null;
|
||||
@@ -73,8 +81,7 @@ function main() {
|
||||
const dir = resolve(stagePlay(opts.lang, opts.dir));
|
||||
const level = resolveLevel(join(dir, "levels"), opts.level);
|
||||
if (!level) {
|
||||
const names = readdirSync(join(dir, "levels")).filter((f) => f.endsWith(".json")).map((f) => basename(f, ".json"));
|
||||
fail(`no level "${opts.level}". Levels: ${names.join(", ")}`);
|
||||
fail(`no level "${opts.level}". Levels: ${levelNames(join(dir, "levels")).join(", ")}`);
|
||||
}
|
||||
if (opts.lang === "ts" && !existsSync(join(dir, "node_modules"))) {
|
||||
console.error(`Installing the TypeScript reference's dependencies in ${dir} ...`);
|
||||
|
||||
+20
-12
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Search a SpellGrid level (Module 10) for solutions, by brute force, with the course's
|
||||
// TypeScript reference engine. Used in Module 13 to check that a level has the solution
|
||||
// its designer meant and no other.
|
||||
// its designer meant and no other; tools/verify-levels.mjs runs it on every level's proof.
|
||||
//
|
||||
// node tools/solve-spellgrid.mjs [level] [--dir <dir>] [--max N] [--naive] [--never-rule "X IS Y"] [--all]
|
||||
// npm run solve:spellgrid -- l1 --never-rule "DEATHRATTLE IS DOUBLE"
|
||||
@@ -10,12 +10,12 @@
|
||||
// does, copies tools/solver/solve.ts beside it and runs it there. `level` is a level of
|
||||
// course/10-spellgrid/levels by name or prefix (`l1`), or a path to a level file; default `l1`.
|
||||
// The other options are the solver's: see tools/solver/solve.ts.
|
||||
import { cpSync, existsSync, readdirSync } from "node:fs";
|
||||
import { cpSync, existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fail, isMain } from "./lib.mjs";
|
||||
import { resolveLevel, stagePlay } from "./play-spellgrid.mjs";
|
||||
import { levelNames, resolveLevel, stagePlay } from "./play-spellgrid.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const WITH_VALUE = ["--max", "--never-rule"];
|
||||
@@ -34,20 +34,28 @@ export function parseArgs(argv) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const dir = resolve(stagePlay("ts", opts.dir));
|
||||
const level = resolveLevel(join(dir, "levels"), opts.level);
|
||||
if (!level) {
|
||||
const names = readdirSync(join(dir, "levels")).filter((f) => f.endsWith(".json")).map((f) => basename(f, ".json"));
|
||||
fail(`no level "${opts.level}". Levels: ${names.join(", ")}`);
|
||||
}
|
||||
/**
|
||||
* Stage the Module 10 TypeScript reference with its cards and levels into `dir` (default: a new
|
||||
* temp directory), copy the solver beside it and install its dependencies. Returns the directory.
|
||||
*/
|
||||
export function stageSolver(dir) {
|
||||
dir = resolve(stagePlay("ts", dir));
|
||||
cpSync(join(here, "solver", "solve.ts"), join(dir, "solve.ts"));
|
||||
if (!existsSync(join(dir, "node_modules"))) {
|
||||
console.error(`Installing the TypeScript reference's dependencies in ${dir} ...`);
|
||||
const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, stdio: ["ignore", "inherit", "inherit"] });
|
||||
if (r.status !== 0) fail("npm install failed");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const dir = stageSolver(opts.dir);
|
||||
const level = resolveLevel(join(dir, "levels"), opts.level);
|
||||
if (!level) {
|
||||
fail(`no level "${opts.level}". Levels: ${levelNames(join(dir, "levels")).join(", ")}`);
|
||||
}
|
||||
const r = spawnSync("npx", ["tsx", "solve.ts", level.path, ...opts.solver], { cwd: dir, stdio: "inherit" });
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//
|
||||
// It never copies engine state. Every position is reached by replaying its inputs on a
|
||||
// fresh level, which needs nothing from the engine but determinism. A position is
|
||||
// remembered by a key built from what the harness shows (every tile's kinds, each
|
||||
// minion's card and Health, the hand, the heroes), plus the choices made for Assemble,
|
||||
// because two Assembled cards can share a name. Two input sequences with the same key
|
||||
// remembered by a key built from what the harness shows (every tile's objects, oldest
|
||||
// first, because age breaks ties in movement, hazards and the win check; each minion's
|
||||
// card and Health; the hand; the heroes), plus the choices made for Assemble, because
|
||||
// two Assembled cards can share a name. Two input sequences with the same key
|
||||
// are treated as one position, so the search finds one shortest solution per winning
|
||||
// position, not every sequence of inputs that wins.
|
||||
//
|
||||
@@ -66,7 +67,7 @@ function key(g: SpellGrid, assembled: string): string {
|
||||
const tiles: string[] = [];
|
||||
for (let y = 0; y < level.height; y++) {
|
||||
for (let x = 0; x < level.width; x++) {
|
||||
const kinds = g.kindsAt(x, y);
|
||||
const kinds = g.stackAt(x, y);
|
||||
if (kinds.length === 0) continue;
|
||||
const minion = g.minionAt(x, y);
|
||||
tiles.push(`${x},${y}:${kinds.join("+")}${minion === null ? "" : `#${g.nameOf(minion)}/${g.health(minion)}`}`);
|
||||
|
||||
@@ -3,8 +3,8 @@ import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { run } from "./helpers.mjs";
|
||||
import { parseArgs } from "../play-spellgrid.mjs";
|
||||
import { run, toolsDir } from "./helpers.mjs";
|
||||
import { levelNames, parseArgs, resolveLevel } from "../play-spellgrid.mjs";
|
||||
|
||||
test("play_spellgrid_parses_args", () => {
|
||||
assert.deepEqual(parseArgs(["rust"]), { lang: "rust", level: "l1", dir: null, stageOnly: false });
|
||||
@@ -30,8 +30,20 @@ test("play_spellgrid_refuses_unknown_level_and_foreign_dir", () => {
|
||||
const r = run("play-spellgrid.mjs", ["rust", "--dir", foreign, "--stage-only"]);
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.out, /not empty/);
|
||||
const bad = run("play-spellgrid.mjs", ["rust", "l9", "--dir", join(foreign, "ws"), "--stage-only"]);
|
||||
const bad = run("play-spellgrid.mjs", ["rust", "l99", "--dir", join(foreign, "ws"), "--stage-only"]);
|
||||
assert.equal(bad.code, 1);
|
||||
assert.match(bad.out, /no level "l9"\. Levels: l1-double-tap, l2-spliced-corpse, l3-simultaneous-paradox/);
|
||||
assert.match(
|
||||
bad.out,
|
||||
/no level "l99"\. Levels: l1-double-tap, l2-spliced-corpse, l3-simultaneous-paradox, l4-crossed-out, l5-sealed-keg, l6-melt-before-defeat, l7-clay-key, l8-nothing-dies, l9-skull-river, l10-friendly-fire\n/,
|
||||
);
|
||||
assert.equal(run("play-spellgrid.mjs", ["java"]).code, 1);
|
||||
});
|
||||
|
||||
test("play_spellgrid_resolves_levels_by_number", () => {
|
||||
// l1 is level 1, not level 10; proofs.json beside the levels is not a level.
|
||||
const levels = join(toolsDir, "..", "course", "10-spellgrid", "levels");
|
||||
assert.equal(resolveLevel(levels, "l1").path, join("levels", "l1-double-tap.json"));
|
||||
assert.equal(resolveLevel(levels, "l10").path, join("levels", "l10-friendly-fire.json"));
|
||||
assert.equal(resolveLevel(levels, "proofs"), null);
|
||||
assert.ok(!levelNames(levels).includes("proofs"));
|
||||
});
|
||||
@@ -122,18 +122,31 @@ test("level_schema_spellgrid_fields", () => {
|
||||
assert.notDeepEqual(validate(levelSchema, { ...level, cards: [{ card: "Powder Keg", x: 1 }] }), []);
|
||||
});
|
||||
|
||||
// Puzzle levels the course ships: course/NN-*/levels/*.json. Every one is valid, stays
|
||||
// on its grid, and names only cards that have data, minions with a noun on the grid.
|
||||
// Puzzle levels the course ships: course/NN-*/levels/*.json (proofs.json beside them is
|
||||
// verify-levels' data). Every one is valid, stays on its grid, writes only words its module's
|
||||
// reference knows and places objects of its nouns, and names only cards that have data,
|
||||
// minions with a noun on the grid.
|
||||
test("course_levels_valid", () => {
|
||||
const courseDir = join(here, "../../course");
|
||||
const cards = new Map(courseCardFiles().map((f) => json(f)).map((c) => [c.name, c]));
|
||||
const files = readdirSync(courseDir)
|
||||
.filter((d) => /^\d\d-/.test(d) && existsSync(join(courseDir, d, "levels")))
|
||||
.flatMap((d) => readdirSync(join(courseDir, d, "levels")).filter((f) => f.endsWith(".json")).map((f) => join(courseDir, d, "levels", f)));
|
||||
.flatMap((d) =>
|
||||
readdirSync(join(courseDir, d, "levels"))
|
||||
.filter((f) => f.endsWith(".json") && f !== "proofs.json")
|
||||
.map((f) => ({ mod: join(courseDir, d), f: join(courseDir, d, "levels", f) })),
|
||||
);
|
||||
assert.ok(files.length >= 3, `found ${files.length} level files`);
|
||||
for (const f of files) {
|
||||
for (const { mod, f } of files) {
|
||||
const level = json(f);
|
||||
assert.deepEqual(validate(levelSchema, level), [], f);
|
||||
const words = json(join(mod, "reference", "ts", "src", "content", "words.json"));
|
||||
const nouns = new Set(words.filter((w) => w.role === "noun").map((w) => w.name));
|
||||
const written = new Set([...words.map((w) => w.name), "IS", "AND", "NOT"]);
|
||||
for (const { kind } of level.objects) {
|
||||
const known = kind.startsWith("text:") ? written.has(kind.slice(5)) : nouns.has(kind.toUpperCase());
|
||||
assert.ok(known, `${f}: ${kind} is not a word or noun of ${basename(mod)}`);
|
||||
}
|
||||
for (const o of [...level.objects, ...(level.cards ?? [])]) {
|
||||
assert.ok(o.x < level.width && o.y < level.height, `${f}: (${o.x}, ${o.y}) is off the grid`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { toolsDir } from "./helpers.mjs";
|
||||
import { checkProof, describe, EXHAUST_MAX, parseArgs, proofProblems, solverRuns } from "../verify-levels.mjs";
|
||||
|
||||
const levelsDir = join(toolsDir, "..", "course", "10-spellgrid", "levels");
|
||||
|
||||
// What the solver prints (tools/solver/solve.ts), cut down to the lines verify-levels reads.
|
||||
const SOLVED_IN = (n) => ({ code: 0, out: `1 input: 3 new positions, 4 in all\nsolution in ${n}: right; cast Whirlwind\n` });
|
||||
const EXHAUSTED = { code: 1, out: "2 inputs: 0 new positions, 9 in all\nno solution: every reachable position was searched\n" };
|
||||
const CUT_OFF = { code: 1, out: `no solution within ${EXHAUST_MAX} inputs (the search was cut off, not exhausted)\n` };
|
||||
|
||||
/** A fake solver: answers each run from `answer(args)` and records the arguments it was given. */
|
||||
function fakeSolver(answer) {
|
||||
const calls = [];
|
||||
const run = async (args) => {
|
||||
calls.push(args);
|
||||
return answer(args);
|
||||
};
|
||||
return { run, calls };
|
||||
}
|
||||
|
||||
test("verify_levels_parses_args", () => {
|
||||
assert.deepEqual(parseArgs([]), { levels: [], proofs: null, dir: null });
|
||||
assert.deepEqual(parseArgs(["l4", "--proofs", "p.json", "l5-x", "--dir", "d"]), {
|
||||
levels: ["l4", "l5-x"],
|
||||
proofs: "p.json",
|
||||
dir: "d",
|
||||
});
|
||||
});
|
||||
|
||||
test("verify_levels_runs_the_solver_once_per_claim", () => {
|
||||
assert.deepEqual(solverRuns({ level: "l2", shortest: 4 }).map((r) => r.args), [["--max", "4"]]);
|
||||
assert.deepEqual(
|
||||
solverRuns({ level: "l1", shortest: 10, neverRule: "DEATHRATTLE IS DOUBLE" }).map((r) => r.args),
|
||||
[["--max", "10"], ["--max", "60", "--never-rule", "DEATHRATTLE IS DOUBLE"]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
solverRuns({ level: "l3", shortest: 6, naiveUnsolvable: true }).map((r) => r.args),
|
||||
[["--max", "6"], ["--max", "60", "--naive"]],
|
||||
);
|
||||
assert.equal(describe({ level: "l3", shortest: 6, naiveUnsolvable: true }), "shortest 6; none under --naive");
|
||||
});
|
||||
|
||||
test("verify_levels_accepts_a_proof_that_holds", async () => {
|
||||
const proof = { level: "l1", shortest: 10, neverRule: "DEATHRATTLE IS DOUBLE" };
|
||||
const solver = fakeSolver((args) => (args.includes("--never-rule") ? EXHAUSTED : SOLVED_IN(10)));
|
||||
assert.deepEqual(await checkProof(proof, solver.run), []);
|
||||
assert.equal(solver.calls.length, 2);
|
||||
});
|
||||
|
||||
test("verify_levels_reports_a_proof_that_fails", async () => {
|
||||
// The fixture proof claims 5 inputs; the solver finds 4, so the claim "none shorter" is false.
|
||||
const shorter = await checkProof({ level: "l3", shortest: 5 }, fakeSolver(() => SOLVED_IN(4)).run);
|
||||
assert.equal(shorter.length, 1);
|
||||
assert.match(shorter[0], /a solution in 4 inputs, shorter than 5/);
|
||||
|
||||
const none = await checkProof({ level: "l3", shortest: 5 }, fakeSolver(() => EXHAUSTED).run);
|
||||
assert.deepEqual(none, ["no solution within 5 inputs"]);
|
||||
|
||||
const cooked = await checkProof(
|
||||
{ level: "l4", shortest: 7, neverRule: "DEATHRATTLE IS DOUBLE" },
|
||||
fakeSolver((args) => (args.includes("--never-rule") ? SOLVED_IN(9) : SOLVED_IN(7))).run,
|
||||
);
|
||||
assert.equal(cooked.length, 1);
|
||||
assert.match(cooked[0], /solved without DEATHRATTLE IS DOUBLE in 9 inputs/);
|
||||
|
||||
const cutOff = await checkProof(
|
||||
{ level: "l3", shortest: 6, naiveUnsolvable: true },
|
||||
fakeSolver((args) => (args.includes("--naive") ? CUT_OFF : SOLVED_IN(6))).run,
|
||||
);
|
||||
assert.equal(cutOff.length, 1);
|
||||
assert.match(cutOff[0], /under --naive was cut off at 60 inputs, not exhausted/);
|
||||
});
|
||||
|
||||
test("verify_levels_rejects_malformed_proofs", async () => {
|
||||
assert.deepEqual(proofProblems({ level: "l1", shortest: 10 }), []);
|
||||
assert.ok(proofProblems({ level: "l1" }).some((p) => /shortest/.test(p)));
|
||||
assert.ok(proofProblems({ level: "l1", shortest: 3, neverRule: "double" }).some((p) => /neverRule/.test(p)));
|
||||
assert.ok(proofProblems({ level: "l1", shortest: 3, naive: true }).some((p) => /unknown key "naive"/.test(p)));
|
||||
const solver = fakeSolver(() => SOLVED_IN(3));
|
||||
assert.notDeepEqual(await checkProof({ level: "l1", shortest: 0 }, solver.run), []);
|
||||
assert.equal(solver.calls.length, 0);
|
||||
});
|
||||
|
||||
test("verify_levels_every_level_has_one_proof", () => {
|
||||
const levels = readdirSync(levelsDir).filter((f) => /^l\d+-.*\.json$/.test(f)).map((f) => f.replace(/\.json$/, "")).sort();
|
||||
const proofs = JSON.parse(readFileSync(join(levelsDir, "proofs.json"), "utf8"));
|
||||
for (const proof of proofs) assert.deepEqual(proofProblems(proof), [], proof.level);
|
||||
assert.deepEqual(proofs.map((p) => p.level).sort(), levels);
|
||||
// Each proof says what the level is for: a rule it can't be solved without, or the naive engine.
|
||||
for (const proof of proofs.filter((p) => p.level !== "l2-spliced-corpse")) {
|
||||
assert.ok(proof.neverRule !== undefined || proof.naiveUnsolvable, `${proof.level} proves nothing about its idea`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
// Check the proofs of the SpellGrid levels (Module 10) with the solver (tools/solver/solve.ts).
|
||||
//
|
||||
// node tools/verify-levels.mjs [level ...] [--proofs <file>] [--dir <dir>]
|
||||
// npm run verify:levels
|
||||
//
|
||||
// Reads course/10-spellgrid/levels/proofs.json (or --proofs), a list of entries
|
||||
// { "level": "l1-double-tap", "shortest": 10, "neverRule": "DEATHRATTLE IS DOUBLE" }
|
||||
// with `neverRule` and `naiveUnsolvable: true` optional, and checks for each one:
|
||||
// - a solution exists with exactly `shortest` inputs, and none with fewer;
|
||||
// - with `neverRule`, the search exhausts every reachable position without a win when
|
||||
// every position in which that rule is in force is thrown away (within 60 inputs);
|
||||
// - with `naiveUnsolvable`, the search exhausts without a win on the engine with the
|
||||
// naive aura timing (`--naive`), within 60 inputs.
|
||||
// `level` arguments pick proofs by level name or prefix (`l4`). The Module 10 TypeScript
|
||||
// reference is staged once, as solve-spellgrid does, into --dir or a temp directory, and
|
||||
// the solver runs there, one process per check, several at a time. Prints one line per
|
||||
// proof; exits 1 if any proof fails.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { availableParallelism } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { courseRoot, fail, isMain } from "./lib.mjs";
|
||||
import { resolveLevel } from "./play-spellgrid.mjs";
|
||||
import { stageSolver } from "./solve-spellgrid.mjs";
|
||||
|
||||
/** How deep a "the search exhausts" check may go before it counts as cut off. */
|
||||
export const EXHAUST_MAX = 60;
|
||||
const KEYS = ["level", "shortest", "neverRule", "naiveUnsolvable"];
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const opts = { levels: [], proofs: null, dir: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--proofs") opts.proofs = argv[++i];
|
||||
else if (argv[i] === "--dir") opts.dir = argv[++i];
|
||||
else if (argv[i].startsWith("--")) fail(`verify-levels: unknown option ${argv[i]}\nusage: verify-levels.mjs [level ...] [--proofs <file>] [--dir <dir>]`);
|
||||
else opts.levels.push(argv[i]);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** What is wrong with a proof entry's shape, or [] if nothing. */
|
||||
export function proofProblems(proof) {
|
||||
const problems = [];
|
||||
if (typeof proof !== "object" || proof === null || Array.isArray(proof)) return ["a proof must be an object"];
|
||||
for (const key of Object.keys(proof)) if (!KEYS.includes(key)) problems.push(`unknown key "${key}"`);
|
||||
if (typeof proof.level !== "string" || proof.level === "") problems.push("level must be a level name");
|
||||
if (!Number.isInteger(proof.shortest) || proof.shortest < 1) problems.push("shortest must be a whole number of inputs, at least 1");
|
||||
if ("neverRule" in proof && !/^[A-Z]+ IS (NOT )?[A-Z]+$/.test(proof.neverRule)) problems.push('neverRule must be a rule such as "X IS Y"');
|
||||
if ("naiveUnsolvable" in proof && proof.naiveUnsolvable !== true) problems.push("naiveUnsolvable must be true or absent");
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** The first "solution in N" depth the solver printed, or null. */
|
||||
const firstSolution = (out) => {
|
||||
const m = /^solution in (\d+): (.*)$/m.exec(out);
|
||||
return m ? { depth: Number(m[1]), line: m[2] } : null;
|
||||
};
|
||||
|
||||
/** A check that the search ran out of positions without a win; `what` names the restriction. */
|
||||
const exhausts = (what) => ({ code, out }) => {
|
||||
const won = firstSolution(out);
|
||||
if (won) return `solved ${what} in ${won.depth} inputs: ${won.line}`;
|
||||
if (code === 1 && /^no solution: every reachable position was searched$/m.test(out)) return null;
|
||||
if (/^no solution within/m.test(out)) return `the search ${what} was cut off at ${EXHAUST_MAX} inputs, not exhausted`;
|
||||
return `the solver failed ${what} (exit ${code}): ${out.trim().split("\n").pop()}`;
|
||||
};
|
||||
|
||||
/** The solver runs a proof needs: the solver's arguments, and a check of its result (an error, or null). */
|
||||
export function solverRuns(proof) {
|
||||
const runs = [
|
||||
{
|
||||
args: ["--max", String(proof.shortest)],
|
||||
check: ({ code, out }) => {
|
||||
const won = firstSolution(out);
|
||||
if (won === null) return `no solution within ${proof.shortest} inputs${code > 1 ? ` (solver exit ${code})` : ""}`;
|
||||
if (won.depth !== proof.shortest) return `a solution in ${won.depth} inputs, shorter than ${proof.shortest}: ${won.line}`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
if (proof.neverRule !== undefined) {
|
||||
runs.push({ args: ["--max", String(EXHAUST_MAX), "--never-rule", proof.neverRule], check: exhausts(`without ${proof.neverRule}`) });
|
||||
}
|
||||
if (proof.naiveUnsolvable) runs.push({ args: ["--max", String(EXHAUST_MAX), "--naive"], check: exhausts("under --naive") });
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** Check one proof. `run(args)` runs the solver on the proof's level and resolves to { code, out }. Resolves to the failures. */
|
||||
export async function checkProof(proof, run) {
|
||||
const problems = proofProblems(proof);
|
||||
if (problems.length > 0) return problems;
|
||||
const results = await Promise.all(solverRuns(proof).map(async ({ args, check }) => check(await run(args))));
|
||||
return results.filter((r) => r !== null);
|
||||
}
|
||||
|
||||
/** One line saying what a proof claims. */
|
||||
export function describe(proof) {
|
||||
const parts = [`shortest ${proof.shortest}`];
|
||||
if (proof.neverRule !== undefined) parts.push(`none without ${proof.neverRule}`);
|
||||
if (proof.naiveUnsolvable) parts.push("none under --naive");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
/** A function that runs async jobs, at most `n` at a time. */
|
||||
function limiter(n) {
|
||||
let running = 0;
|
||||
const waiting = [];
|
||||
const next = () => {
|
||||
if (running >= n || waiting.length === 0) return;
|
||||
running++;
|
||||
const { job, done } = waiting.shift();
|
||||
job().then((r) => {
|
||||
running--;
|
||||
done(r);
|
||||
next();
|
||||
});
|
||||
};
|
||||
return (job) => new Promise((done) => {
|
||||
waiting.push({ job, done });
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/** Run the solver in the staged `dir` on the level at `path`. Resolves to { code, out }. */
|
||||
function solve(dir, path, args) {
|
||||
return new Promise((done) => {
|
||||
const child = spawn("npx", ["tsx", "solve.ts", path, ...args], { cwd: dir });
|
||||
let out = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (out += d));
|
||||
child.on("close", (code) => done({ code: code ?? 1, out }));
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const proofsPath = opts.proofs ?? join(courseRoot(), "10-spellgrid", "levels", "proofs.json");
|
||||
let proofs = JSON.parse(readFileSync(proofsPath, "utf8"));
|
||||
if (!Array.isArray(proofs)) fail(`${proofsPath}: expected a list of proofs`);
|
||||
if (opts.levels.length > 0) {
|
||||
proofs = proofs.filter((p) => opts.levels.some((l) => p.level === l || String(p.level).startsWith(`${l}-`)));
|
||||
if (proofs.length === 0) fail(`no proof for ${opts.levels.join(", ")} in ${proofsPath}`);
|
||||
}
|
||||
const dir = stageSolver(opts.dir);
|
||||
const limit = limiter(availableParallelism());
|
||||
const checks = proofs.map((proof) => {
|
||||
const level = typeof proof.level === "string" ? resolveLevel(join(dir, "levels"), proof.level) : null;
|
||||
if (proofProblems(proof).length === 0 && level === null) return [`no level "${proof.level}"`];
|
||||
return checkProof(proof, (args) => limit(() => solve(dir, level.path, args)));
|
||||
});
|
||||
let failed = 0;
|
||||
for (const [i, failures] of (await Promise.all(checks)).entries()) {
|
||||
const proof = proofs[i];
|
||||
if (failures.length === 0) console.log(`ok ${proof.level}: ${describe(proof)}`);
|
||||
else {
|
||||
failed++;
|
||||
console.log(`FAIL ${proof.level ?? "(no level)"}: ${failures.join("; ")}`);
|
||||
}
|
||||
}
|
||||
console.log(failed === 0 ? `${proofs.length} proofs hold` : `${failed} of ${proofs.length} proofs fail`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) main();
|
||||
Reference in new issue
Block a user