Zen sand gardens are often intended for meditation, whether to look at or while maintaining it by raking the sand into the prescribed patterns which are formed by the tines of the rake yielding parallel lines (like |||). Such a chore might be tedious, but there is also a great deal of satisfaction to restoring an esthetic order from chaos, and raking sand seems like one of those things, like popping bubblewrap, which can be oddly satisfying—I recall the lifeguards at my summer camp seeming to rather enjoy the task of raking the beach in front of their office every day and not allowing any campers to do so. Puzzle-solving is also often described as being ‘meditative’ and ‘oddly satisfying’, even when it looks a lot like a tedious chore. So for those of us who do not live at a Zen temple, can we make a Zen sand garden puzzle?
This page turns the original 2023 sketch into a full design. Where the sketch speculated (would “mirror raking” be too restrictive? could a neural net rank solutions by beauty? how should difficulty be raised?), we answer with a solver and measurements; where it left gaps (what exactly makes a garden hard, what the worlds are, how levels are generated, what the player touches), we fill them. The title is Samon (砂紋, “sand crest”), the term for the patterns themselves.1
The Garden
Pieces. A garden is a walled rectangle of white gravel—in Kyoto, typically Shirakawa-suna, weathered granite from the Kitashirakawa area (Nabunken)—divided into square cells, with stones as obstacles. One cell on the veranda side is the gate: the gap in front of the kutsunugi-ishi, the flat stone below the veranda where one removes one’s shoes (JAANUS). The player is an unsui, a novice monk, and the garden is viewed, as at Ryōan-ji, from the abbot’s veranda: the bottom edge of the screen.
The rule. The novice steps off the shoe-stone, walks into the sand, puts down his rake, and walks backwards to the gate, dragging the rake over his own footprints. The garden is finished when every cell of sand has been raked exactly once, the lines are unbroken, and he is back on the shoe-stone. That is the whole rule: one stroke, every cell once, no crossing, ending at the gate.
(1) A 6×6 garden with 2 stones; the gate is the gap in the bottom wall.
(2) The player draws the walk in from the gate; the monk leaves footprints.
(3) At the end of the walk he sets down the rake (red dot) and walks backwards, the rake erasing his footprints.
(4) The finished garden: 10 turns, which is par; 2 of this garden’s 1,023 valid rakings reach it.
Formally: Hamiltonian paths. Formally, the sand cells form a grid graph, and a valid raking is a Hamiltonian path of that graph which ends at the gate cell. The rake lines are the path’s shape: a cell traversed straight gets parallel grooves; a cell where the path turns gets concentric quarter-circles; the cell where the rake was set down gets the groove’s start. Every claim below about counts, pars, and difficulty comes from an exact solver for this formulation (see Methods).
Walking in is free. The original sketch distinguished the forward walk (leaving footprints) from the backward raking, and proposed “mirror raking”—the monk retraces his walk exactly—as a possibly-too-restrictive simplification. It is not a restriction at all. Any walk in, followed by a raking that covers every cell exactly once and ends at the gate, erases every footprint, because every cell gets raked; so the walk in is irrelevant except for where it ends, and the set of finished gardens is exactly the set of Hamiltonian paths ending at the gate. Conversely, every such path is achieved by walking it in and raking it out.2 So mirror raking loses nothing, and the interface can be the simplest possible: draw a path from the gate; that path is the walk in; the raking is its reverse.
This matters for design because the story stays intact (the reveal is the monk raking himself out of the garden) while the player’s task is a single, legible gesture.
The Bare Rule Is Loose
Too many rakings. The one-stroke rule alone is a weak constraint in open sand. For an empty garden with a corner gate, the number of valid rakings is the number of Hamiltonian paths from a corner, OEIS A145157, which grows explosively: 824 for 5×5, 1,510,446 for 7×7, 54,986,690,944 for 9×9. (Our solver reproduces A145157 and the 5×n sequence A145156 exactly, which is a useful correctness check.) Anyone can rake an open garden: walk up and down in rows.
| Garden | Valid rakings | Par (min. turns) | Rakings at par | Median turns | Max turns | Wide-rake rakings |
|---|---|---|---|---|---|---|
| 3×3 | 8 | 4 | 2 | 5 | 6 | 2 |
| 4×4 | 52 | 6 | 4 | 8 | 13 | 4 |
| 5×5 | 824 | 8 | 10 | 13 | 20 | 4 |
| 6×6 | 22,144 | 10 | 24 | 20 | 31 | 6 |
| 7×7 | 1,510,446 | 12 | 66 | 27 | 42 | 6 |
| 6×8 | 899,432 | 11 | 66 | 26 | 42 | 8 |
Parity. Stones make the rule bite, but mostly by making gardens impossible. Color the cells like a checkerboard: every step of the stroke changes color, so a stroke over n cells alternates colors, and if one color has more cells, the stroke must begin and end on it. A 5×5 garden has 13 dark and 12 light cells, so its gate must be on a dark cell; move the gate one cell along the wall and there is no raking at all. Of random 7×7 gardens with a gate somewhere on the veranda wall, 57% pass this parity test with no stones and only 31% with 6 stones.
A 5×5 garden has 13 dark and 12 light cells, so the stroke must start and end on dark cells.
(Left) Gate on a dark corner: 824 valid rakings (one at par, 8 turns, shown).
(Right) Gate one cell over, on a light cell: no raking exists.
Random stones: unsolvable or open. Past parity, stones create dead ends (a cell with one free neighbor must be where the rake is set down—at most one such cell may exist) and corridors (a cell with two free neighbors must use both). These are the classic deductions of one-stroke puzzles, and they are real, but random stone placement produces them in the wrong proportion: gardens are either unsolvable or still have thousands of rakings. In 300 random 7×7 gardens per row, none with 0–6 random stones had a unique raking:
| Stones | Parity OK | One stroke: solvable | One stroke: unique | One stroke: median rakings | No U-turns at all: solvable | Wide rake: solvable | Wide rake: unique | Wide rake: median rakings |
|---|---|---|---|---|---|---|---|---|
| 0 | 57% | 57% | 0% | ≥100,000 | 0% | 57% | 0% | 6 |
| 2 | 43% | 42% | 0% | 28,326 | 0% | 18% | 2.7% | 2 |
| 4 | 34% | 20% | 0% | 610 | 0% | 2.7% | 0.3% | 3 |
| 6 | 31% | 8% | 0% | 96 | 0% | 0.3% | 0% | 4 |
The bare rule therefore cannot carry a game by itself: completion is trivial in open sand, and the obstacles that make it non-trivial mostly make it impossible. Two things fix this: a score that makes the slack matter, and rules that remove it. Pleasingly, both come from what makes a raked garden beautiful.
Calm: Turns As Score
The abbot counts turns. The commonest samon are long parallel lines, interrupted where they ring a stone. A raking that zigzags looks agitated. So the game’s single esthetic metric is the number of turns: every cell where the stroke changes direction. Fewer turns is calmer. Each garden has a par, the minimum number of turns over all valid rakings, proven by a constraint solver (in a few seconds at most, for gardens up to 9×9). Any valid raking finishes the garden; a raking at par earns the abbot’s vermilion seal.
(Left) Par, 17 turns: 6 of its 103,812 valid rakings achieve it.
(Middle) A median raking, 31 turns.
(Right) The most agitated possible raking, 48 turns (5 rakings).
Par is a needle. Turns make the looseness useful. The distribution of turns over all valid rakings is a broad hump, and par sits in the far tail: in the empty 7×7 garden, 66 of 1,510,446 rakings (0.004%) reach par, and the median raking has more than twice as many turns. Finishing a garden is easy; finishing it calmly is a real optimization puzzle, of the kind where intuition (“make long lines; circle the stones; use the walls”) gets close and the last two turns take thought.
Histograms. This is the structure of Zachtronics optimization games, where after solving, the player’s solution is ranked against every other player’s on a few metrics (Opus Magnum compares cycles, cost, and area). Samon shows one histogram per garden after completion: the player’s turn count against everyone’s, with par marked—a picture very like the one above, filled in by real players. Because par is proven, the seal is never a trap (no “par” that nobody can reach) and never a lie (no raking can beat it).
Par tends to look traditional. The surprise is that minimizing a single number recovers the traditional motifs. In the open and lightly-stoned gardens we inspected, par rakings were nested U-bends and spirals: long parallel lines across the garden (sazanami), and concentric rings around stones (mizumon), without either being specified. The rings appear because circling a stone is the cheapest way to use the cells next to it; the parallel lines appear because every turn costs. This is not a law, though: with crowded or awkwardly-placed stones, par rakings degenerate into meanders full of tight U-turns in open sand. Among 60 random solvable gardens (6×7–7×8, 1–3 stones), only 40% admitted any raking free of open-sand U-turns, and only 20% had a par raking that was free of them; so “par is beautiful” is a property for the level curator to select for (and we do: see Generating Gardens), not something the rule guarantees.
Why not a neural esthetic score? The sketch proposed ranking solutions with a learned esthetic model, such as the CLIP-based predictor behind LAION-Aesthetics. For scoring the player, a hand-countable metric is better: the player can see it, count it, and deliberately optimize it, and a proven optimum exists; an opaque neural score can be none of these, and would invite adversarial raking. The learned model is better spent upstream, ranking layouts (which stone arrangements look like real gardens, which par rakings are prettiest when several tie), where opacity costs nothing.
Mechanics
One rule per world. The standing criticism of Zen Puzzle Garden, the closest ancestor, was monotony: “there’s still a persisting feeling that, even by the final few stages, Zen Puzzle Garden hasn’t developed one step beyond what the tutorial already demonstrated” (Wilson 2011). So each world adds one rule, and each rule must earn its place by changing how the player thinks, not just the scenery. Every rule below is diegetic—a thing in the garden or a property of the rake—and every one was tested with the solver on random gardens before being kept. Here are the candidates that survived, in the order the game introduces them, and then the ones that did not.
Ripple Stones
Rings. A ripple stone (marked with faint concentric rings in the abbot’s sketch) must be encircled: its 8 neighboring cells must be raked as one unbroken arc, entering once and leaving once. This is mizumon, the rings-around-an-island motif, as a hard constraint instead of an esthetic tendency.
Texture, not tightness. The surprise of testing was how little it constrains. In the garden below, the ripple rule cuts the number of valid rakings from 183,986 (with an ordinary stone) to 35,570, and leaves par unchanged at 12; 4 of the 5 par rakings already ringed the stone. Across random 7×7 gardens with one ripple stone (and 0–2 plain stones), the median number of rakings was still 5,573. Ripple stones teach “rings are entered from outside and left from the cell next to the entrance” and make the reveal prettier, but they cannot make a garden tight on their own.
(Left) The abbot’s sketch marks the stone with rings: its 8 neighbors must be raked as one unbroken arc.
(Right) The par raking (12 turns; 4 of 35,570 rakings) starts at the stone and spirals outward, the ripple becoming a whirlpool.
Wave Bands
Lines that run through. A wave band is a hatched region of the sketch: inside it, every row (or column, for a vertical band) must be raked as one straight, unbroken run, edge to edge. This is sazanami: the long parallel ripples that most gardens are mostly made of. Mechanically, each row of the band becomes a corridor that must be crossed in one go, so the player must plan where the turnarounds go outside the band.
Somewhat tighter. Bands constrain more than rings: in the garden below, the band cuts 1,492,008 rakings to 2,984, and raises par from 16 to 17 (the calmest raking of the bare garden does not run its lines where the abbot wants them). But across random 7×7 gardens with a band (and 0–2 stones), the median count was still 3,799.
(Left) The hatched band must be crossed by straight runs, edge to edge.
(Right) A par raking: 17 turns, one of 34 at par among 2,984 valid rakings.
Ichimatsu. Alternate horizontal and vertical bands in a checkerboard and you get ichimatsu. Here the band rule finally becomes tight, because every cell now belongs to a run and the stroke can turn only at run ends, handing off from one run to the next: the 9×9 garden of nine 3×3 blocks below has exactly 2 valid rakings, versus 54,986,690,944 for the empty 9×9 garden, and the 6×6 version (four blocks) has exactly 1 for most gate positions. These make good end-of-world showpieces: a real checkerboard samon, drawn in one stroke.
(Left) Nine 3×3 blocks, raked alternately across and down.
(Right) One of its 2 valid rakings (both 45 turns).
The Master’s Grooves
Continue his lines. The abbot has begun the raking and been called away: a few cells already carry grooves, and the finished garden must be one stroke that runs along them. (Re-raking a groove in its own direction leaves no trace, so the novice may pass through these cells, but only following them.) These are the “partial raking” of the original sketch, and are the game’s general-purpose clue: unlike abstract numbers or symbols, a given groove is self-explanatory, since it is a piece of the answer.
Uniqueness for ~5–8 grooves. Grooves are how free gardens become strict ones. The level generator picks a beautiful raking as the intended answer—a par raking with no U-turn in open sand—and adds grooves from it until it is the only valid raking left. In the garden below, 5 grooves cut 12,375 rakings to 1, and the answer is a par raking (21 turns; 1 of 11 at par). Over 69 generated 7×7–7×8 gardens, the median was 7 grooves.
(Left) Five cells are pre-raked; the stroke must run along them.
(Right) The unique raking (21 turns, which is also par).
Two kinds of groove. The abbot can also leave finished work: a closed ring raked around a stone, as real gardens have. A single stroke can never close a ring, so these are effectively obstacles—and a way to put the iconic closed ripple into gardens without violating the one-stroke rule. They are also the “partial raking which makes an insoluble problem soluble” of the sketch: pre-raked cells are removed from the stroke’s job, which changes the garden’s topology (and, if the finished shape covers unequal numbers of dark and light cells, its parity).
The Abbot’s Pebbles
Straight or turn, nothing more. A weaker clue than a groove: the abbot leaves a pebble on a cell. A pale pebble means the stroke runs straight through that cell, across or down; a dark pebble means it turns there, either way. A pebble’s cell is never where the rake is set down, since that cell has only one connection. (The “any turn” clue comes from a parallel design by GPT-6 Astra; Nikoli’s Masyu uses white and black pearls the same way, with extra conditions on the neighboring cells that pebbles do not have.)
More clues, harder deduction. We made 88 generated 7×7–7×8 gardens unique both ways, from the same intended raking (a graceful par raking) by the same greedy procedure. Grooves needed a median of 7 clues and pebbles 11 (more in 92% of gardens), but the pebble gardens were much harder: 92% needed lookahead by our grader (median 7 steps) and 12.5% nested lookahead, against 47% (median 2 steps; 58% in the pipeline’s separate sample, below) and none for grooves. A groove gives away a piece of the answer; a pebble is a disjunction to be resolved against its neighbors, which is the kind of deduction the strict worlds want. So pebbles come after grooves in world 4, and supply the hardest strict gardens of world 7. Below, 8 pebbles leave exactly one of a garden’s 59,498 rakings; the same answer needs only 6 grooves, which make the garden tier 1, where the pebbles make it tier 3.
(Left) A 7×7 garden made unique by 6 grooves: tier 1, every step forced.
(Middle) The same garden and answer made unique by 8 pebbles (pale: straight through; dark: a turn): tier 3, 14 lookahead steps.
(Right) The only raking: 13 turns, which is par (6 of the bare garden’s 59,498 rakings reach it).
The Wide Rake
No U-turns in open sand. The novice is promoted and given the senior’s rake, which is too wide to pivot. With it, the stroke may turn 90° anywhere, but it may make a tight U-turn (two consecutive turns the same way) only when the U is braced against a wall or a stone; and the rake must be set down in a cell touching a wall or a stone, never in the open. The abbot’s finished sand (his closed rings, above) is neither: turning against it would rake it. A reversal in open sand therefore needs something inside it: a stone to wrap around, or a nest of wider U’s around a stone. The esthetic motivation is the same as for counting turns: a groove that doubles back on itself in open sand is the ugliest thing a raking can contain, and in real gardens lines end at walls and stones, not in open gravel.
(Left) A tight U-turn in open sand is forbidden.
(Middle) Against a wall, it is allowed.
(Right) A wider U-turn around a stone is allowed.
It tightens everything. This one physical rule does what the pattern constraints could not. In empty gardens it leaves 2–8 rakings out of up to millions (see the first table); in random 7×7 gardens with 2 stones, 18% are solvable and the median solvable garden has 2 rakings; uniqueness, which the bare rule did not produce in any of the 1,200 random 7×7 layouts, appears by itself (2.7% of 2-stone layouts). Forbidding every U-turn, braced or not, is too much: no empty garden of any size we tried, and none of 1,200 random 7×7 gardens, could be raked at all.
Often the calm answer. The striking result is which raking survives. The 8×8 garden below has 6,310,226 valid rakings under the bare rule; 12 of them reach par (17 turns); exactly one of those 12 has no U-turn in open sand; and that one is the garden’s only raking under the wide rake. This is not a fluke of one garden, though it is not a law either: over 964 random gardens with a unique wide-rake raking, that raking was a par raking of the unrestricted garden in 32% of cases, and in 47% of the calm-looking ones (turn density ≤0.40) that the curator would keep. So the novice’s tool often selects the very raking the abbot would have chosen; but it is a stricter rule than his taste, not the same one (see Free, then strict).
(Left) Two stones.
(Right) Its only valid raking under the wide rake (17 turns): a spiral around one stone, a horseshoe around the other, parallel lines everywhere else. It is the only one of the garden’s 12 par rakings without a U-turn in open sand.
Hard. Wide-rake gardens are the hardest in the game by our grader: every one we generated required lookahead (assume a move, propagate, find a contradiction), and about a quarter required nested lookahead. The player-facing heuristics—“every reversal needs a pivot”, “count the cells inside each U”, “lines run wall to wall”—are learnable, but this needs playtesting more than anything else in the design, and is why it comes fifth rather than second.
The Side Gate
Both ends fixed. Some gardens have a second, side gate, and the novice must rake his way in from it (walking backwards from the start, so leaving no footprints at all) and out by the veranda. Fixing both ends of the stroke doubles the force of parity (both endpoint colors are now given) and removes the player’s freedom of where to begin. The stroke comes in through the wall, so a bend in its first cell counts as a turn, as leaving by the veranda gate does. Under the bare rule this is still loose (the garden below has 311,080 such rakings); combined with the wide rake it gives a unique, graceful answer that is also par.
(Left) The stroke must begin at the side gate (top, outlined) and end at the veranda gate.
(Right) The only valid raking under the wide rake: 20 turns, counting the bend just inside the side gate, which is also par for the bare rule (32 of the 311,080 rakings reach it).
Rejected Mechanics
Mechanics we considered and dropped, with the reason for each.
- Ice-sliding: Zen Puzzle Garden’s rule (the monk slides until blocked, and may only turn when blocked) is the obvious alternative core. It is taken, and it makes the puzzle about stopping places rather than shapes.
- Braced turns only (90° turns allowed only next to walls or stones): a softer cousin of ice-sliding. Too strict: only 31% of empty 7×7 gardens remain solvable, and almost no gardens with stones.
- No U-turns at all: infeasible; see above.
- Pushable lanterns, ordered leaves: Zen Puzzle Garden’s later additions (JayIsGames). They import Sokoban into a game whose identity is the line.
- Repairing a disturbed garden (a deer walked through overnight; re-rake only what you must): it sounds rich but is trivial. For the repaired garden to be one stroke, the grooves the novice leaves untouched must be an initial segment of the old stroke, ending where his new raking begins; so the cheapest repair is always to re-rake everything from the first damaged cell (in the old stroke’s order) back to the gate, and the route he takes doesn’t matter.
- Multiple monks / multiple strokes / stepping stones that lift the rake: each adds slack to a rule whose problem is already slack.
- Bridges over a dry stream (the only cells the stroke may cross): workable (Flow Free: Bridges does it), but untested here, and it weakens “never cross your own lines”, the rule that makes the fiction work.
- Symmetric gardens (mirror-image halves raked in mirror image): against the grain of the esthetic, which prizes fukinsei, asymmetry (Japanese aesthetics), and impossible anyway. A symmetry of the garden that preserves the stroke maps the path onto itself, and a path’s only symmetries are the identity and reversal; reversal would swap the gate’s cell with the rake’s start, so it must be the identity, which fixes every cell of the stroke: all the sand would have to lie on the mirror line (Astra’s argument). Local symmetry, two marked patches raked in mirror image, is possible; Astra proposes it as an optional rule, untested here.
Progression
Seven worlds, one day. The worlds follow one day at the temple, dawn to moonrise, which gives each a lighting (and so a look: low sun makes grooves cast long shadows; noon flattens them; moonlight silvers them) without any text. Each introduces exactly one rule; gardens grow from one-cell corridors to 9×9.
| World | Time | New rule | What it teaches | Gardens (main path + extras) |
|---|---|---|---|---|
| 1. First Stroke | Dawn | One stroke from the gate; stones | A dead end is where the walk in must end; corridors force the route; parity | 9 + 10 |
| 2. Ripples | Morning | Ripple stones | Rings: entered once, left beside the entrance | 10 + 10 |
| 3. Waves | Noon | Wave bands; ichimatsu | Runs are atomic; plan turnarounds outside them | 11 + 9 |
| 4. The Master’s Grooves | Afternoon | Given grooves (open & closed); then pebbles | Continuation; reading a unique answer; resolving “straight or turn” against the neighbors | 16 + 9 |
| 5. The Wide Rake | Evening | No U-turn in open sand; set rake down at an edge | Every reversal needs a pivot | 9 + 17 |
| 6. The Side Gate | Dusk | Two gates | Both ends fixed; double parity | 9 + 9 |
| 7. Silver Sand | Night | All of the above, larger gardens | Synthesis | 8 + 12 |
Free, then strict. The arc runs from optimization to deduction. In worlds 1–3 any valid raking finishes a garden and the seal rewards calm; the player learns, by seeing their own rakings next to par, that calm rakings are long lines and rings. From world 4 on, gardens have one valid raking, and the rules (grooves, then the wide rake) enforce a calm that the first half only rewarded. It is not quite the same calm. The generator keeps a free garden only if some par raking has no U-turn in open sand, but par is usually reached by others too: in 42 of the 54 free gardens of worlds 1–3 the par rakings include both kinds, and 327 of all 407 par rakings there turn back in open sand somewhere (Waves, for instance, has 34 par rakings, and 32 of them do). So the wide rake is a stricter rule than the abbot’s taste, to be learned as a new rule, not the taste made physical (Astra’s correction: an earlier version of this page claimed the latter). Curating the free worlds so that every par raking is graceful would leave too few gardens (11 of 54 qualify), so instead the wide rake’s introduction makes the difference visible: its first garden is one whose par raking turns back in open sand (below). The seal works the same way throughout; in a strict garden every valid raking is at par, so there it marks a garden raked without the abbot’s help. The final world is named for the “sea of silver sand” at Ginkaku-ji, raked “so that in moonlight its ridges appear as ripples in water” (Cartwright 2019).
A short main path. Each world has a short main path, which is the template below and nothing more (8–16 gardens; 72 in all), followed by optional extras (76), listed after it as “more”: the rest of the generated gardens, for practice and for seals. In the first version of the campaign every garden was on the path, so grooves came after 38 gardens and the wide rake after 52 (Astra’s count, from the unlocking rule); now they come after 21 and 32.
Opening worlds. A world opens once two-thirds of the main path of the world before it has been raked, at any number of turns; extras raked instead count too. Par is never required, and any garden can be finished, by persistence or with the abbot’s help, so no player is ever locked out; within an open world every garden is open, so a player stuck on one rakes others.
The first five gardens. Teaching is wordless, by construction: (1) a one-cell-wide corridor (drawing, and the reveal); (2) a 3×3 garden with a corner gate (more than one answer, and U-turns at the walls; the turn counter and par appear); (3) the first real garden, the easiest of world 1’s generated ones (5×5, one stone), where long lines turning at a stone first appear; (4) a garden with a one-cell pocket behind a stone (the pocket can only be where the rake is set down); (5) a garden of two-tone gravel in which the obvious place to end the walk is the wrong color, so every attempt ending there strands a cell (parity, made visible; the two tones fade to uniform white over the next two gardens). The real garden comes before the two techniques, which only matter at its size: an early player, on a phone, did not “sense the potential until about six levels in”, which is where the first real garden then came. A 2×3 garden that taught the U-turn was dropped to make room; the 3×3 garden’s par rakings turn back at the walls anyway.
Inside a world. Each world after the first follows one template (Astra’s): an introduction (a contrasting pair, below); two applications; a garden whose obvious move, learned from the gardens before it, is wrong; two combinations with earlier rules; a calm, easy garden; and a capstone. Every garden is stored with its intended raking, the one observation it teaches, and the abbot’s first hint, which states that observation; the test for keeping a garden is what fact changes the player’s plan? A garden is cut if the only way to solve it is trial and error, or if its clues trace the answer outright.
Show the change. Each later rule is introduced by a contrasting pair (also Astra’s suggestion): a small garden raked as usual, then the same garden with one change, both found by search. A ripple stone, a band, or the wide rake is added to a garden whose every par raking breaks the new rule, so the plan that just worked visibly fails; one groove cuts a garden’s 8 rakings to one; pebbles, one pale and one dark, replace the two grooves that pinned another garden down; a side gate leaves one of a garden’s two wide-rake rakings, and not the par one. The pairs have 17–23 cells of sand; the first campaign introduced bands in a 55-cell garden, and grooves and pebbles in 47-cell ones, which now come after two smaller applications. The changed garden of each pair is demonstrated before the player rakes it (see Interface).
The campaign. The prototype has all 148 gardens. Thirty-four are teaching gardens: 13 of the prototype’s original 14 (the 2×3 garden was dropped), the corridor, three ichimatsu variants, four small introductions found by search (among them the two-tone parity garden), and the 13 gardens of the contrasting pairs. The other 114 come from the pipeline below, with garden-like stones: for each size and mechanic, it generates up to twice as many candidates as needed (235 from 16,437 sampled layouts, in 33 minutes of computing) and keeps a subset spread over their difficulty range, no two alike up to rotation and reflection (apart from the pairs, which are alike on purpose). Half of that time went on failing to find a single 9×9 pebble garden (none of 227 layouts passed the filters), so world 7’s pebble gardens are 8×8.
Ordered by the template. Each world is put in the template’s order, with every role assigned by measurement. Applications run from easy to middling (the hardest are extras): free gardens by size, then by how rare par is among their rakings; strict gardens by grade. The trap is the garden whose obvious move is most clearly wrong: in a free garden, the one whose key observation (below) the near-misses, rakings one or two turns over par, most often get wrong (between 74% and 94% of them, in worlds 1–3); in a strict garden, one whose only raking turns aside at once, where walking straight in from the gate can be refuted only by lookahead. The combinations add a rule from an earlier world (a second ripple stone; a ripple stone among bands; grooves or pebbles with a ripple or a band; the wide rake with either; pebbles at a side gate), generated for the purpose. The calm garden is the one with the fewest turns per cell among the easier half of the rest, and the capstone is the hardest, or a hand-made showpiece (Calm, Spiral, Side gate, Silver sand). A rule introduced mid-world (ichimatsu; the abbot’s finished rings; his pebbles) gets its own short run of gardens after the trap.
Hints that change the plan. Each generated garden’s first hint is the one provable fact that most changes the player’s plan, and the abbot points at the cells it names. In a free garden, it is the fact true of every par raking (where it ends; how it begins; a row, column, or stretch of one raked in one straight line; whether the line turns or runs straight in some cell) that the most near-misses get wrong: a median of 50% of them. In a strict garden, it is the fact about the only raking (where the rake is set down, or the line’s shape in one cell) that most lowers the grade; when the rake is set down in a cell with one way in, the hint says so, giving the reason before the place. Of the 80 strict gardens with such a hint, 62 need lookahead without it; with it, 16 need none, the 10 that needed nested lookahead need only one level, and the other 36 need fewer steps. Of the 18 that need no lookahead anyway, it lets 4 be solved by local rules alone (the other 14 already are). In a strict trap, the observation is the trap itself: walking straight in goes wrong. None of this is curated or playtested: it is the raw material for step 3 of the production plan, not its result.
Daily garden: ichi-go ichi-e. One generated garden per day, the same for everyone, alternating free and strict. The player may draw and undo freely, and here nothing is raked until they press Rake (the only garden with the button); that first raking is the day’s record—“one time, one meeting” (ichi-go ichi-e). The result shared is the seal (or not) and turns versus par, with the garden’s shape but not the stroke, which spoils nothing (everyone else is still raking it that day). The prototype has it: a year of 366 gardens made by the campaign’s generators and never used in it (even days free, odd days strict; 5×5 to 8×8; ripple stones, bands, grooves, pebbles, the wide rake and side gates all appear, with the rule stated in a line, since the daily garden does not wait for the player to reach the world that teaches it), picked by the local date, so everyone gets the same garden on the same day and the year repeats. Drawing and undoing are free; Rake commits; the raking is then final for the day, with the record, the poem (the campaign’s, by day of the year) and a streak of consecutive days shown, a share to pass on (“Samon · daily garden 2026-10-04 · 14 turns, par 12 · 静 · streak 3”, the garden’s shape drawn in box-drawing characters without the stroke, and a link that opens today’s garden: see Sharing below), and a new garden tomorrow. LinkedIn’s Zip, a daily Hamiltonian-path puzzle, suggests the appetite is there.
Generating Gardens
Pipeline. Handmade levels set the curriculum; generated ones fill it, and supply the daily garden. The pipeline:
- Sample a layout: garden size, stones, gate on the veranda wall, and the world’s markings. Stones are placed the way a garden designer would: an odd number of pieces (1, 3 or 5), each of 1–3 cells (single, straight or L-shaped), none within a cell of another, with no mirror or half-turn symmetry, and covering at most ~15% of the garden. (The experiments elsewhere on this page used single-cell stones; the two samplers are compared below.)
- Reject on parity (instant).
- Count valid rakings with the exact solver (milliseconds for ≤8×8; the empty 7×7 garden’s 1.5 million rakings take 2 seconds).
- Prove par with the constraint solver, and also the best turn count achievable with no U-turn in open sand; reject free gardens where the two differ (their par raking is a meander, and the seal would reward an ugly answer).
- Strict gardens: keep wide-rake layouts that are unique by themselves, or add grooves or pebbles from the graceful par raking until it is unique.
- Grade with the deductive solver (below), and reject or reorder.
- Curate the survivors for layout esthetics (a learned model could help here; see above), and write each one’s observation and first hint.
Yields. Strictness is cheap to generate. Of 1,151 random 7×7–7×8 layouts, 69 (6.0%) passed all filters for groove gardens, needing a median 7 grooves. Of 95,673 random 7×7–8×8 layouts with 1–3 stones, 2,444 (2.6%) were unique under the wide rake with no grooves at all; 58% of those had a turn density (turns per cell) of at most 0.40, which in practice separates calm answers from meandering ones. At these rates, a year of daily gardens takes on the order of a CPU-hour; the prototype’s year took 8 minutes of generation on two cores (src/daily.py), plus the campaign’s unused candidates.
Garden-like stones cost nothing. Realistic stone groups do not make good gardens rarer. On 1,000 random 7×7 layouts from each sampler (garden-like layouts averaged 4.7 stone cells, single-cell ones 2.0), fewer garden-like layouts were solvable at all (28% against 39%), but the filters discard those anyway: usable free gardens came slightly more often (4.7% of layouts against 3.8%), gardens unique under the wide rake about as often (2.2% against 2.7%), and unique side-gate gardens slightly more often (1.8% against 1.3%). Groove gardens needed the same median of 6 grooves; on small samples (25 each), the garden-like ones needed lookahead less often (28% against 84%), plausibly because larger stones make more corridors, which are local deductions.
Grading. Difficulty is estimated by a deductive solver that works like a human, on the state of each boundary between cells (raked through, or not):
- Tier 1, local rules: a cell with two raked boundaries closes its others; a cell with exactly two open boundaries uses both; a cell with one is where the rake is set down; no closed loops; grooves, pebbles, bands, rings, and the wide rake’s ban on open U-turns.
- Tier 2, global rules: checkerboard parity of the starting cell, connectivity of the unraked sand, and no premature joining of the stroke’s two ends.
- Tier 3, lookahead: assume a boundary is raked through, propagate tiers 1–2, and reject it on contradiction.
- Tier 4, nested lookahead.
Generated groove gardens were 38% tier 1, 4% tier 2, and 58% tier 3 (median 3 lookahead steps); wide-rake gardens were 74% tier 3 (median 16 steps) and 26% tier 4. Within a world, gardens follow the template (Progression), and its strict applications are ordered by tier, then lookahead count, then size. These tiers are a proxy; only playtesting can say whether, for instance, the wide rake’s lookahead is the kind players enjoy or the kind they resent.
Interface
Show, then do. A garden that brings in something new is first raked once by the monk himself: a ghostly fingertip leads him in from the gate along the intended raking, he rakes his way back out, the result rests for a moment (“That is a calm way to rake it: 3 turns, which is par. Now you.”), and the garden is smoothed for the player. This happens in the first garden (what raking is: in play, the first garden alone left it unclear what to do), in the first one scored by turns, and at the introduction of each later rule; it plays once by itself, Watch replays it, a tap skips it, and it costs nothing. Watching the answer to an introduction spoils little, since an introduction is not meant to be hard; what it teaches is what the rule looks like when obeyed.
Draw from the gate. Before the walk, the monk waits in the gateway, with a ring that pulses around him three times and then rests (it pulsed without end at first, which nags once the idea has been had); the player drags him in, and the path snaps cell to cell and leaves footprints. Until a player’s first walk, and always in the first garden, a ghostly fingertip then demonstrates the drag itself, three times; a press anywhere else says where to begin, and demonstrates again. (The prototype first marked the gate’s cell with a dashed circle, which says nothing about where to start, or that one drags.) The walk follows the direction the hand is moving, not the cell the pointer happens to be in: arms are not mounted on tracks, and a hand sweeping along a row drifts off it, so a drag from one end of a row to the other that strays into the next row still draws the row (the first prototype snapped to whichever cell the pointer entered, and a long run would hop rows partway). The axis of motion holds until the motion itself turns to within 30° of the other axis, or until the pointer is two cells off the run; a turn first finishes the run as far as the pointer went, so a rounded corner comes out as a corner; the direction is read over the last 0.4 cell of motion, so a wobble in place reads as nothing; and the pointer is captured, so the grip survives leaving the field or the window, a pointer beyond the walls being read as the nearest cell. (This is the submenu problem of hierarchical menus, solved the same way: the intended movement is what matters, and a cursor that leaves the menu item on a slight diagonal toward the submenu has not left it.) Dragging back over the last footprint retracts it; tapping any footprint walks the monk back to it; tapping the gate starts over. Since either can throw away a long route by accident, the last rewind or restart can always be undone (Restore). A rewind says what it leaves (“Stepped back 3 footprints: par is still possible from here”), replacing whatever verdict stood before, and the garden keeps the keyboard afterwards (after Astra’s note that arrow keys scrolled the page after “Step back 3 for par” and that the old impossibility message lingered). Moves into stones, walls, or footprints are ignored, and so is any move that breaks a rule on the spot (an open-sand U-turn under the wide rake, a turn at a pale pebble), with a soft scrape and a brief judder of the rake; a route that is doomed but not yet broken stays drawable, since noticing the trap is the puzzle. A turn counter and the par sit at the top of the screen; nothing else does. Whatever the game says to the player, as opposed to shows (“Drag the monk in from the gate”, “Morning is open: 20 new gardens”, a locked world’s note, the help, the scroll’s counts) is set in square brackets, as gwern.net sets the editor’s words to the reader; rules, poems, counts and controls are not. Every control, count and mark carries a tooltip with the word more that a hover might want (what Par is and who proved it, what Ask the abbot costs, what a world’s extras are, what the seal says), as gwern.net puts one on everything that would bear it; the garden itself has none, since a tooltip over the sand would follow the hand through every walk.
Letting go rakes. When the walk covers every cell and the finger lifts, the monk rakes; from the keyboard, the last step is followed by a beat in which it can still be taken back. Raking is not final: tapping any cell of the raked garden smooths it back to the footprints, as far as that cell, and the walk goes on from there (and Restore rakes it again as it was), so a player who sees 19 turns against a par of 17 backs up with one tap, having seen the pattern. An earlier design asked for an explicit Rake press after every garden (Astra’s); in play it was a confirmation for an action that costs nothing, and it is kept only in the daily garden, where the first raking is the record.
Sharing. A raking is worth passing on, and the page has no server, so the raking itself travels in the link: Share this raking gives the share sheet (or the clipboard) the raking drawn in box-drawing characters—light lines for the stroke, heavy for the walls with a tee where the gate breaks them, blocks for the stones—under a line with the garden, the turns, the par and the seal, and a link whose hash holds the raking as a code. The code is small because the rules are strict about what a walk can do next: at each step there are at most three legal moves (and often one), so the walk is written as a number in the mixed radix of its legal moves, step by step in the order north, east, south, west, and a whole raking of a 7×7 garden takes some 20–50 bits (a mean of 22 bits over the campaign’s intended rakings, against 86 at two bits a step; with the garden’s identity and whether the abbot helped, 8–17 characters of base64url). The garden is named in the code by 32 bits of a hash of its layout and rules, never by its place in the campaign, so a link survives a reordering, and a garden changed since is recognized as another rather than mistaken for the one meant (Astra’s point: an 8-bit check, the first version, would have collided once in 256, and a seal must never land on a different puzzle). No code decodes to a walk that breaks a rule, since the decoder re-derives the legal moves as it goes, and a code whose garden is gone or changed, or whose walk does not fit, is refused with a word. Opening such a link shows the garden raked, with its seal, and says whose it is: it is credited to nobody, the viewer’s own progress is untouched, and any tap smooths it away and leaves the garden theirs to rake from the gate (a raking so received cannot be walked back into, or restored). The same idea carries a player’s whole progress between devices without an account: Carry my progress makes a link whose hash lists every raked garden, by the same layout hash, with its state (raked and by how many turns over par, sealed in grey, sealed), the daily records by date with today’s raking, and the unfinished walk of the garden last open, with the abbot’s mark on it: about 7 characters a garden and 4 a day, so some 140 characters for 20 gardens and 2,500 for everything. Opened on another device, it is offered rather than taken (“This link carries a player’s progress: 23 of 148 gardens raked, 19 sealed, 4 daily gardens… Take it over here?”), and taking it joins the two: whichever side has the better state of a garden keeps it, the first raking of a day stays its record, and an entry whose garden is not in this edition, or has changed, is left out and said so, never assigned to the garden now at its place. A link ending in #daily opens today’s garden. (The alternative, a shared raking as its index in a canonical enumeration of the garden’s rakings, would be smaller still for free gardens, but a million-raking garden cannot be enumerated in the browser; the mixed radix is the same ordering, counted from one end, without the enumeration.)
Phone geometry. Portrait phones are ≈390 points wide, so a 9-cell garden gives ≈42-point cells, about the 44-point minimum touch target; that sets 9×9 as the largest garden that needs no zoom, and the design never exceeds it. The same interface works with a mouse.
The reveal. When the walk is complete, the monk turns, sets down the rake, and walks backwards to the gate, pulling it; then the camera tilts from top-down to the abbot’s view from the veranda, low sun on the ridges, and the seal is stamped or not. The rake must glide: it follows the stroke’s centerline continuously, through every quarter-circle, at a walking pace that eases in where it is set down and out at the gate, carving lit grooves and erasing the footprints as it passes, with a few grains flicked aside by the tines. (The prototype’s first reveal drew finished cells one at a time as line art; it read as chunky and unsatisfying, and the reveal is the game’s payoff, so it is worth the most polish.) It lasts a few seconds, scaled to the garden’s size, and a tap skips it.
The abbot judges your route, not his. Asking the abbot never reveals his answer; it decides, by search, what the player’s own route can still become, and says only what it can prove. Gardens this small are decided quickly (the slowest of the prototype’s 4,440 test routes took 0.7 seconds in a desktop browser). There are three verdicts. The route can still be raked at par: he sketches its next groove, taken from a par raking that continues the player’s route. It can still be finished, but not at par: he marks the last footprint from which par was possible and says so with the action first (“step back 3 footprints to the marked footprint for par”), and offers the step. It cannot be finished: he says why when there is a short reason (sand cut off, two dead ends, the wrong count of dark and light cells), marks the cells involved, and offers to step back to the nearest footprint from which the garden can still be finished, proven to be the nearest (if, in a large garden, that proof runs out of budget, he offers a footprint from which it can be finished, without calling it the nearest). In a fresh garden the first request gives instead the garden’s observation (“the top-right corner has only one way in”). If a search ever runs out of budget, he says he cannot tell yet, rather than guess.
Help costs the vermilion. The observation is free, and so is the reason a walk has stopped (no step on is allowed, or every cell is covered but the walk can’t end there), since a player can see that much already; an answer the abbot cannot give in time is free too. Anything more—a verdict on a live route, a sketched groove, where to step back to—marks the garden as helped, and a helped raking at par earns the abbot’s seal in grey ink instead of vermilion. Astra argues for making every diagnosis free and charging only for a revealed move; but a verdict on a live route (“this can still reach par”) is an oracle, which a player can consult after every step to search the garden mechanically, and the seal would then certify nothing. The mark lasts until the garden is next raked, so starting over does not wash it out, and it travels with the route it helped: walking back into a helped raking and restoring it rakes it grey again (an early version let Step back and Restore turn a grey seal vermilion; Astra found it). Raking the garden again unaided can still earn the vermilion seal, and the garden list keeps the better of the two (seals are drawn square, rakings off par as round dots). The redemption is deliberate: a player can memorize a helped answer and rake it again, but making one peek cost a garden’s seal forever would punish curiosity more than it rewards honesty. Assists (below) do not affect the seal. This replaces an earlier design, in which a hint revealed one groove of the intended raking; in free gardens that hint contradicted valid routes the player was already on. (Hints that judge the player’s own route, with proven rewinds, are Astra’s idea.)
Assists. Off by default after world 1, on toggles: a faint preview of the grooves under the footprints; the checkerboard; and hatching on sand the route has cut off from the gate. There are no timers, move limits, or failure states; an impossible position is simply one the player has not yet noticed.
Below the garden. Under the garden sit the turn count against par and three buttons (Step back, Start over, Ask the abbot; Restore, Watch, Rake and Next garden appear when they apply), then the status line, the assists (left in view, since a new player cannot know what an assist is until shown), a garden’s rule where it brings one in, the list of gardens, and the help, folded into one line. The list shows the worlds that are open, each as its main path, with the extras folded behind “+10 more” (unfolded by themselves while one of them is the garden open); a world not yet open is one muted line with its count and what opens it, so a new player sees a short list that lengthens as the day goes on. Sound, volume and haptics live under a chevron beside the speaker in the header rather than in the list; the counts of a garden’s rakings live in the tooltip of its par. (The first version showed every world’s pips, greyed, the three sound controls in the list, a “Sand” counter, the rule for every garden, and 290 words of help; on a phone the page ran to 2,400 pixels, and now to 1,400, with everything that was cut a hover or a tap away.)
Accessibility. Every rule-bearing mark differs in shape, not only in color: hatching for bands, dashed rings for ripple stones, pale against dark pebbles, a dashed outline for cells the abbot points at, hatching for cut-off sand. The game is fully playable from a keyboard (arrows walk, Backspace steps back, Enter skips the reveal and goes on) and respects reduced-motion settings, as the prototype already does. A text description of each cell (terrain, mark, footprint, monk) with a coordinate-grid view would support non-visual play; that needs testing with screen-reader users rather than assuming.
Prototype. The one-page prototype at the top of this page implements this interface without the camera tilt: worlds that open in turn (with a switch that opens them all, for testing), main paths and extras, the monk’s demonstrations and the first-drag hint, the monk waiting at the gate, drawing, rewinds and Restore, the turn counter against par, raking on letting go and stepping back after it, the refusals, the abbot’s three verdicts, the three assists, the gliding reveal, the vermilion and grey seals, the poem after each raking and the fade to the next garden, the scroll of poems earned, the daily garden, the practice garden, the sound (the synthesized pipe, drips, wind, birds, crickets, footsteps, scrape, bell and tok; the shakuhachi recording; haptics), and the sharing (rakings and progress as codes in the URL’s hash) (all in Presentation and Progression). It has the whole campaign of 148 gardens (see Progression), each with its poem; every par and raking count shown in it comes from the solvers, and its hint search agreed with the C solver on every one of 4,440 test routes (none ran out of budget). Where the game would teach wordlessly, the prototype states each new rule in a line of text.
Presentation
Sand as a heightfield. The original sketch suggested a falling-sand particle simulation. Raking does not make sand flow; it displaces it, so the right representation is a heightfield computed per pixel in a fragment shader: each cell’s groove type (straight, quarter-circle around the corner, or the rake’s start) gives the distance to each tine’s centerline, a smooth groove profile turns distance into height, and the height’s gradient is lit by a low sun whose angle follows the world’s time of day. Footprints are depressions stamped by decal; the reveal is a mask advancing along the stroke. This is cheap on any phone, resolution-independent, and gives the one thing that matters visually: ridges catching light. The prototype does a simpler version on the CPU, with about one height sample per screen pixel (32 to 144 per cell, so the sand is never visibly upscaled): footprints are pressed into the field as the player walks in, the abbot’s grooves are raked into it in advance, and the reveal writes the rake’s cross-section along the stroke frame by frame, the same engine as the practice garden (below). Gravel grain is procedural and never the same twice: two octaves of value noise in the height (so the ridges catch light unevenly), a per-pixel fleck and a slow mottle in the color, a few specks on top, and a fresh seed every time a garden is laid out. The rake is set down flush with whatever is behind its first cell (wall, stone, or the lane raked before), so no patch of sand is left between the start of the stroke and its neighbor. The grid is an abstraction, and the rendering admits it: a turn is drawn as an ideal quarter-circle stamp, which no rigid rake sweeps exactly; the picture is faithful to the stroke, not to rake physics.
Practice garden. A garden with no rules is always open: drag anywhere to rake, curves and all; place or lift stones (the sand under a lifted stone stays unraked); smooth it flat and start again. Here the heightfield is written directly: the rake head, held perpendicular to its motion, sets the sand’s cross-section (a ridge between every pair of tines) along its path, and the same low sun lights it. It costs almost nothing, and it is the honest home for the sketch’s falling-sand toy: a sandbox for the hands, kept apart from the puzzles so it never weakens their rules (Astra’s suggestion). The prototype has one.
Palette. White-grey granite gravel, sumi-e ink for stones and walls, moss green around the larger stones, and one accent: the vermilion of the novice’s ensō and the abbot’s seal (the two marks of a brush in the garden; the first prototype had a vermilion triangle in the gateway as well, redundant with the gap in the wall and with the monk waiting in it, so it is gone, and only a side gate’s exit keeps an outline triangle). No sepia or yellow cast; the gravel at Kyoto temples is white. The stones are ink-wash paintings, not 3D models; Ōkami shows how far sumi-e can go in motion. In the prototype each stone is drawn as one: a contour that is lumpy and a little rough, flatter at its base where it sits in the sand; a soft contact shadow cast down and to the right, where the sand’s light is from, as the blurred shadow of one path (so the two parts of a bent stone never darken each other), with a tighter, darker one at the base; an ink wash lighter toward the light; and a brushed edge, one band of varying weight, heavy where the ink pools on the shadow side and thinning to nothing where the brush ran dry on the lit side (in dark mode the edge is the pale rim light, so it is heavy on the lit side instead). This replaced the first prototype’s smooth capsules with a highlight, after GPT-6 Astra’s second review of it; a first attempt with per-segment strokes and stepped washes looked jagged and banded, which Gwern caught, so everything is now one smooth band or gradient. The novice is an ensō: one brushed circle, coming down rounded and heavy, thinning as it goes round, lifting before it closes, with two dry strands trailing past the lift, in vermilion, rubricated like an initial (the first prototype drew a filled black disc); he is drawn at the head of the walk, and, walking backwards, behind the rake; while he waits in the gateway he is its marker. Everything around the garden follows gwern.net’s design: black text on white, greys for whatever is secondary, serif text with sans-serif controls, square corners, hairline rules, no shadows, and dark mode as gwern.net has it (#161616 under #f1f1f1); the vermilion stays the one colour, on the novice’s ensō and the seal, and the abbot’s marks, the hint highlights and the faint route through the footprints, which the first prototype tinted blue and red, are ink.
Sound. The garden has its own sounds and almost no music. A bamboo pipe drips into a stone basin now and then (every 7–23 s), and every minute or two the pipe tips onto its stone with a hollow knock and spills a few drops; a bonshō bell sounds once at the close of each world, its low partials beating and lasting; the seal lands with a dull wooden tok. Under them a wind moves, a bed of low filtered noise that wanders in level and colour every few seconds and swells into a gust every half-minute or so; by the local clock, small birds sing by day (phrases of two to six gliding notes, every 12–45 s, from 6 to 19) and crickets trill after dark (a high sine pulsed at about 28 Hz, from 20 to 5), so a page left open follows the evening. Each footprint is a step on gravel (a burst of grains over a soft thump), and the raking is heard: the rake’s scrape is grains of gravel under the tines, a looping crackle through a bandpass whose loudness, brightness and density follow the stroke’s speed frame by frame, with the rasp of tines dragged sideways in every bend and the monk’s backward step at every cell, lifting as the rake goes out through the gate. All of these are synthesized in the page with Web Audio (decaying sines, filtered noise and synthesized crackle; a short feedback delay for the basin’s echo), so they cost nothing to carry and never repeat exactly. After a world’s bell, and now and then otherwise (never within a quarter-hour of the last), a shakuhachi recording follows, faded in over 3 s and out over 4: the recordings are real and public domain, carried in the page as data: URLs with their credits and sources shown on the scroll of poems. The first is Shika no Tōne (鹿の遠音, “The Distant Cry of Deer”), the Kinko-school honkyoku for two shakuhachi answering each other, played by Araki Kodō III (1879–1935) on Victor 13029 between 1925 and 1935, from Wikimedia Commons (a Japanese phonogram whose neighbouring rights have expired; the de-noised derivative released as public domain by its maker), re-encoded as mono MP3 at 32 kbps (1.5 MB). Nothing sounds until the player has touched the page, as browsers require, and the first time it does a word beside the speaker says so. Silence is one tap away: a speaker button in the header (or M) mutes everything, Sound off in the list of gardens is the same switch, a slider beside it sets the volume, and all of it is remembered. The page also defers to what the browser and the device say. There is no CSS media query for a sound preference (Media Queries Level 5 has prefers-reduced-motion, -transparency, -contrast, -data and forced-colors, nothing for audio), so the signals that exist are used instead: where the browser has the Audio Session API (Safari 17), the page’s session is ambient, so a phone’s silent switch mutes it and it mixes with whatever the player was already listening to rather than stopping it; the system’s media keys (pause, stop) mute it while a recording plays, through the Media Session API, which also names the piece on the lock screen; and when the browser keeps the page’s audio from running after a gesture (a site set to block sound, or Firefox’s autoplay policy answering disallowed), the speaker shows it in red instead of pretending to play. Haptics tick once per cell (8 ms, where the device can; a switch beside the sound’s, shown only on such devices).
A poem for each garden. The reward for raking a garden, at par or not, helped or not, is a poem: a moment after the reveal, a classical waka or haiku is laid over the raked sand in English on a white card with a hairline border (the first prototype blurred the grooves through a frosted plate), with the poet’s name set in by an indent (no dash, and no kanji: the first version put 原文 beside the name as the button for the original, and the poet’s name in kanji over the source, which Gwern found clutter), and a tap on “original” adds the Japanese and its source. About ten seconds later the garden and its poem fade together and the next garden fades in, so the campaign can be played as one slow, unbroken sequence; a tap on the poem goes on at once, a tap beside it (on the sand around the card, or the walls) puts the poem away and stays with the garden (a player’s note: interacting with the poem should go forth, dismissing it should go back; the first prototype had any tap stay), Next garden or Enter goes on at once, and nothing moves on while the page is hidden or scrolled away. The poem rewards finishing, not par, so it never punishes asking for help; the seal still records how the garden was raked. Every poem earned is kept on a scroll (Poems, beside the practice garden), in the order of the gardens with their Japanese and sources, with a count of what remains in each world, so the 76 extras have something to give besides seals; a garden’s name on the scroll leads back to it.
The poems are real, and all written before 1900 (all but one by poets who died before it): 149 (one for each garden, and one for the practice garden) by 25 poets, from Bashō (34), Teika (23), Buson (20) and Rengetsu (13) down to single poems by Tsurayuki, Izumi Shikibu and Shiki. Each is paired by image or by structure. The first garden’s poem is Bashō sweeping a temple garden before he goes on (“Let me sweep the garden / before I go— / willow leaves fall in the temple.”). The garden before the first ripple stone gets Ryōkan’s new pond, where the frog jumps in and makes no sound; the stone arrives with Bashō’s old pond. The pair of gardens that introduces wave bands gets the two drafts of his Kiyotaki poem: waves “without a speck of dust” under the summer moon, and then, as he rewrote it on his deathbed, waves with green pine needles scattering into them. The last garden of the main path gets the one classical poem about temple sand hauled and tended by monks, Bashō’s at Kehi shrine, where the Yugyō abbots carried the sand in themselves: “The moon is clear / on the sand / the pilgrim monk carried.” The English versions are working translations, not quotations of published ones (see Credits).
No koans. There is no narrative text, and no invented wisdom: apart from the rules, the only words are the poems, which are real. The novice’s progress is shown by his rake (bamboo, then the wide rake) and his robes (in the prototype he is an ensō, see Palette); the abbot appears only as a seated figure on the veranda and as his seal. Fake Zen aphorisms (or fake haiku) would cheapen exactly the thing the game is trying to be.
Prior Art
- Zen Puzzle Garden (Lexaloffle 2003): raking as a puzzle, with the ice-sliding rule (“rake all of the sand without turning corners or breaking raked lines”); 64 gardens across 4 seasons, an editor, and later pushable lanterns and leaves to collect in order. Its solvability is NP-complete (Houston et al 2012).
- Zen Rock Garden (ZenRock 2021): “Just trace over all the sand panels with a single stroke!”, 300+ stages, with famous temple gardens as late motifs. This is the bare one-stroke rule, so Samon does not claim it; what is new here is the gate-anchored walk-in/rake-out reveal, proven-par scoring, the samon constraints, and the wide rake.
- One-stroke and fill-the-board path puzzles: hitofude-gaki (一筆書き) apps; Flow Free (2012: “Pair all colors, and cover the entire board”); Zip (2025; Hamiltonian path through numbered waypoints, with walls); Nikoli genres like Numbrix/Hidato, whose given path fragments resemble the master’s grooves, and Masyu, whose pearls resemble the pebbles.
- Line puzzles with deep progression: The Witness (constraints on a drawn line, taught wordlessly); LYNE (2014); Cosmic Express (2017; routing a single non-crossing track).
- Optimization metrics: Opus Magnum and other Zachtronics games’ histograms.
- Exits and pulling: Stephen’s Sausage Roll (the puzzle includes getting out), and Sokoban, of which raking is a kind of inverse: one pulls a rake rather than pushing blocks.
The general problem is NP-complete, as Hamiltonian path is NP-complete even on grid graphs (Itai et al 1982), so no simple trick solves every garden; but garden-sized instances are trivial for a computer, which is what makes proven pars, proof-backed hints, and generated levels possible.
Production Plan
Rules before art. The order of work (Astra’s plan, adapted): (1) prove the rules in a plain prototype—drawing, undo, the rules, the hint search, a few gardens—before any art (done: the prototype attached to this page); (2) build a vertical slice of 12 gardens on real phones: the first five gardens, a ripple garden, a band garden, a groove garden, a pebble garden, a wide-rake garden, a side-gate garden, and a daily garden; one polished reveal; the histogram screen; (3) curate the campaign from generated candidates, several times more than needed, ordered by grade and revised by the mistakes players actually make (a first, uncurated pass is in the prototype); (4) only then the sensory layer: the shader, sound, haptics, low-power rendering, and accessibility passes.
Pass or fail. The slice passes if players can say why a route failed after asking the abbot, carry an observation from one garden to the next, and choose to rake a finished garden again for the seal. If they brute-force, shrink the gardens; if they only trace forced moves, remove clues; if the reveal feels like a wait, shorten it; if they spend their time in the practice garden instead of the gardens, the puzzles are the problem, not the sand.
Risks and Open Questions
- The free worlds may feel trivial. Finishing an open garden is easy, and par is optional. Mitigation: keep world 1 short (9 gardens on its main path), give the seal real status, and introduce grooves (world 4) by the second hour (after 21 gardens). One early outside player raked at least 26 gardens on a phone, in the longer first campaign, and meant to play on.
- The wide rake may be too hard. The grader says every wide-rake garden needs lookahead. Mitigation: start it at 5×5, use the hint, and order by grade; if playtesting is bad, move it to an optional post-game.
- Genre saturation. One-stroke grids are a crowded genre. The differentiators are esthetic: the reveal, the score that is the beauty, the diegetic constraint vocabulary.
- Scoring details. Decided: leaving through the gate counts as a turn, and U-turns braced against walls are not free (as they are in real raking). Both stay simple, and the turn count already rewards long lines; either would be a one-line change, but would change every par.
- Parity frustration. Players who never discover parity will hit unwinnable endgames repeatedly. The two-tone tutorial gravel and the checkerboard assist are the mitigation; whether they suffice is an empirical question.
- Esthetic curation. Turn density and “no open U-turns” are crude proxies for beauty. A learned layout scorer, validated against human ratings of rendered gardens, is the obvious next experiment.
- Hints as a crutch. Proof-backed hints are strong: pressed repeatedly in a par-reachable route, the abbot sketches the whole answer. Decided after a first play session: helped rakings earn a grey seal (see Interface); Astra’s design kept hints free and unpenalized, and its review argued for free diagnosis; explanations of a stopped walk are now free, verdicts on a live route are not. Open: whether players will memorize helped answers to re-rake them for vermilion, and whether that matters.
- Demonstrations may teach copying. Showing the calm raking of each introduction makes the rule’s look concrete, but the player then retraces it. If playtests show players learning the shape rather than the rule, demonstrate each rule on a separate, smaller garden, and leave the pair’s changed garden to the player.
Methods
How the numbers on this page were computed, and how they were checked. Everything is reproducible from the code, which is in the appendix.
Exact solver. A depth-first search in C draws strokes from the gate inward (the reverse of raking; turns and U-turns are symmetric under reversal). Sand cells are bits in a 128-bit mask (gardens ≤128 cells). Each node prunes on: connectivity of the remaining sand through the head (a bitwise flood fill); degree (every unvisited cell needs two usable neighbors, except at most one, which must be the rake’s start); checkerboard parity of the remaining cells; required and forbidden directions per cell (encoding bands and grooves); pebbles (a pale pebble’s cell needs both neighbors on one axis usable, a dark pebble’s one on each axis); ring contiguity; and the U-turn rules. It enumerates the 1,510,446 rakings of the empty 7×7 garden in 1.9 seconds (33 million nodes).
Validation. Its counts for empty gardens match OEIS A145157 (n = 3–7) and A145156 (5×4, 5×6). Against an independent brute-force enumerator (no pruning; rules checked only on complete paths) on ≈340,000 random small gardens (≈67,000 of them solvable) exercising every rule, it agreed on every count and every turn histogram; and on medium gardens it agreed with a separately-written CP-SAT model on counts, histograms, and minimum turns. Pebbles were checked the same way, on 6,000 further random small gardens against brute force and 108 medium ones against CP-SAT, with no disagreements; the grader’s pebble rules never derived a contradiction in a solvable garden, and every garden it solved, it solved to the right answer (711 random gardens, 79 of them with a unique raking). Astra’s review found two rules that the solvers and the prototype shared, and so could not catch by agreeing with each other: the bend just inside a side gate was drawn but not counted (in 13 of the 18 side-gate gardens then in the campaign), and the abbot’s finished sand counted as a pivot for the wide rake. Both were fixed in all three solvers and in the prototype, and checked again on 3,192 random small gardens against brute force (411 solvable, 132 of those with a side gate and 149 with finished sand) and on 120 medium ones against CP-SAT, with no disagreements. The recheck also found a bug in the CP-SAT model: a sand cell walled in by finished sand has no arcs, and a circuit constraint silently leaves such nodes out, so the model “solved” gardens that cannot be raked; every cell now needs exactly one incoming arc.
Par. The CP-SAT model is a single circuit through all sand cells plus a dummy node (arcs from the dummy to any start cell, and from the gate cell back to the dummy), with straightness indicators per cell and direction, so turns = (cells − 1) − Σ straight; rings constrain the number of arcs entering the ring set to exactly 1; bands and grooves fix edges; a pebble fixes its cell’s straightness indicator and excludes it as the start; U-turns are three-literal clauses. It proves par for random 8×8 gardens in 0.2–1.8 seconds, and for the empty 9×9 garden in 4.4 seconds (2 CPU cores).
Grader and generator. The grader is the tiered propagation solver described in Generating Gardens, in Python. Groove clues and pebbles are chosen greedily (each round, of ~24 random candidate cells from the intended raking, add the one leaving the fewest valid rakings), then pruned of redundant ones. Yields count every sampled layout, including those rejected on parity.
Hint search. The prototype’s hints run a JavaScript depth-first search that uses the same rule code as the player’s moves and the C solver’s pruning (connectivity, dead ends, parity, pebbles), with a node budget. “Can this route still reach par?” needs no search: each garden ships with every one of its par rakings, and a route can reach par exactly when it is a prefix of one of them. Two things keep the search small. It remembers every position it has proved hopeless, where a position is the footprints, the head, and the last two moves (all the rules ever look at), so a dead end reached again by another route costs nothing. And because a completion of a route also completes every shorter part of it, whether a route can be finished switches from yes to no only once along it, so the nearest footprint to step back to is found by binary search, and is proven nearest by a single proof of impossibility at the footprint after it. On 4,440 routes over all 148 gardens (random legal walks, and prefixes of real rakings), every verdict it gave matched the C solver’s, and no search ran out of budget (an earlier campaign had one such route, in a 9×9 wide-rake garden, where the abbot said he could not tell yet); of the 2,011 rewinds offered from stuck routes, 1,989 were proven nearest and matched the C solver’s nearest footprint, and the other 22 (in three of the largest gardens) were footprints from which the garden could be finished, offered without the claim.
Credits. Several ideas on this page come from a parallel design for the same sketch by GPT-6 Astra, “The Last Footprint” (unpublished): hints that judge the player’s own route, with proven rewinds; restorable rewinds and an explicit commit (now kept only for the daily garden); the practice garden; the “any turn” clue (the dark pebble; we added the pale one); the world template and per-garden records; the production plan and its pass/fail criteria; and the symmetry argument. Its five example gardens all check out under our solver. Astra later reviewed the prototype: it found four bugs (the grey seal washed out by Step back and Restore; the uncounted side-gate bends; finished sand as a pivot; an inconclusive hint that cost the seal), a dead end explained by the wrong rule (a side gate’s cell blamed on the wide rake), and the overclaim that the abbot’s taste is the wide rake’s physics, and it proposed the short main path and the contrasting pairs. Astra also chose the poems: a sourced waka or haiku, with a working translation, for each garden of the earlier 136-garden campaign. We carried them over to the 148 gardens (garden names follow layouts, so most pairings survived the reordering), paired the 13 new gardens (with 3 poems new to the selection), replaced two poems (Shiki’s) whose texts we could not confirm, checked every Japanese text and attribution against a page that prints it, and revised 22 of the translations. Two further passes (ours, then Astra’s review of it) replaced 25 pairings judged weak (a felicitation poem, filler, poems fastened to their gardens by a pun or by a shared noun) with better classical poems, among them Ryōkan’s new pond before Bashō’s old one, Bashō’s master carpenter for the abbot’s grooves, the Man’yōshū moon-boat for the star garden, Tamemasa’s unseen carp under trembling lotus leaves, and Saigyō’s snipe at the threshold; Astra also offered two original poems of its own, which we left out, so that every word the player reads is still a real poem. The texts, sources, pairings, credits and the log of replacements are in poems/samon-poems.json.
License
CC0. Everything here that is ours—Gwern’s contributions and all the LLM-written work: this document, the design, the solvers, experiments, tests and build scripts, the level generator and the 148 gardens and 366 daily gardens it produced, the prototype and its sounds (every one synthesized in the page), the figures, the English working translations of the poems, and the ideas merged from GPT-6 Astra’s parallel design—is released into the public domain under CC0 1.0. No attribution is required; none of it may be used to claim that Gwern or a contributor endorses a derived work.
The poems are classical Japanese texts, all first published before 1900, and in the public domain everywhere; the pages each text was checked against are linked beside it in the game and in poems/samon-poems.json.
The one recording, Shika no Tōne played by Araki Kodō III (Victor 13029, recorded between 1925 and 1935), is File:Shikanotoone_new.ogg on Wikimedia Commons, re-encoded as mono MP3. Commons records it as public domain: a Japanese phonogram whose neighbouring rights have expired under Article 101 of the Japanese Copyright Act, with the performer dead for over seventy years, and the de-noised derivative (Torsodog, 2008) released into the public domain by its maker. In the United States, sound recordings fixed before 1946 came under federal copyright by the Music Modernization Act for 100 years from publication, so a pressing from 1930 is not public domain there until 2031; Commons hosts the file as public domain in both Japan and the United States, and we rely on that.
The page’s look follows gwern.net’s own stylesheets (colours and font stacks), which are CC0; no fonts are bundled, and no third-party code or images are included. The design document’s figures are our own renders; the puzzle counts quoted from the OEIS (A145157, A145156) are facts, not copies.
See Also
Appendix: Code and Data
Download all 223 files as one .zip (1.8 MB; SHA-256 b60754f3fba988794a433d5d8c43cdf4a3160a07b05de806d7b1a664828b74d4).
Start with src/zgsolve.c (exact search), src/zg.py (the rules, the solver driver and a brute-force checker), src/zcp.py (CP-SAT par proofs), src/grader.py (the deductive grader), and src/verify_doc.py, which re-derives every number in the document. The build and test commands are in the guide below. Not included: this page itself and the prototype's two pages, which standalone/build_standalone.py and proto/build.py write.
Code behind the numbers and figures in the Samon design document (2026-10-03, revised 2026-10-04), and the playable prototype. The single-file edition of the document carries this whole tree in its appendix: the code is shown there, and every file is in the .zip it offers (all but the three generated pages, which the build scripts write).
Build
cd src && gcc -O3 -march=native -Wall -Wextra -o zgsolve zgsolve.c
pip install ortools cairosvg playwright # CP-SAT optimiser; SVG→PNG for figures; browser test of the prototype
playwright install chromium
pip install beautifulsoup4 # (and pandoc) for the single-file edition
# ffmpeg with libmp3lame, for sound/make_sound_bundle.py (the recordings; see sound/README.md)
Paths: the scripts use absolute paths under /home/claude/zen/ (the directory this tree was developed in). To run from wherever this tree is unpacked, rewrite them once, from the top of the tree:
grep --recursive --files-with-matches --exclude=README.md /home/claude/zen . | xargs sed --in-place "s|/home/claude/zen|$PWD|g"
Files
src/zgsolve.c: exact DFS solver (counts/enumerates rakings; turn & U-turn histograms; branch-and-bound min turns). Rules: one stroke ending at the gate; optional ripple rings, wave bands / ichimatsu, given grooves, pebbles (shape: S = straight through, B = turn), no-U-turn (nohairpin 1), wide rake (nohairpin 2+endcells), fixed rake start (two gates:end, andendside, the wall the side gate is in, so that a bend in its cell counts as a turn), the abbot’s finished sand (R: not walkable, and not a pivot for the wide rake).stream 1prints every solution (used to export all par rakings).src/zg.py: garden model (text symbols:.sand,#stone,Oripple stone,h/vwave bands,=/|ichimatsu blocks;clues= grooves,marks= pebbles), solver driver, rule checker, brute-force enumerator.src/zcp.py: independent CP-SAT model (AddCircuit + dummy node) for par proofs and cross-checks.src/grader.py: tiered deductive solver used to grade difficulty.src/gen.py,clues.py,levels.py,search*.py: layout sampling, greedy groove and pebble selection, level searches.src/exp_marks.py,fig_pebbles.py: pebbles vs grooves on the same gardens (writesout/marks_vs_grooves.json), and the pebbles figure.src/render.py,final_figs.py: figure rendering (writesfig/final/*.png|svg,out/facts.json).src/final_tables.py,exp_*.py: the tables and statistics quoted in the document.src/verify_doc.py: re-derives every number quoted in the document (C solver + CP-SAT); prints OK/BAD.src/test_crosscheck.py,xc_stats.py,xc_cpsat*.py: solver validation (brute force; CP-SAT; OEIS A145157/A145156).src/test_marks.py,test_grader_marks.py: pebbles: C solver vs brute force vs CP-SAT; soundness of the grader’s pebble rules.src/garden_sampler.py: the garden-like stone sampler (odd-numbered, asymmetric groups of one- to three-cell stones; ripple stones, wave bands, closed rings, side gates, ichimatsu layouts).src/exp_sampler.pycompares its yields with single-cell stones (writesout/sampler_yields.json).src/campaign.py: generates the campaign (7 worlds, 148 gardens): generated gardens per world from the sampler and the pipeline’s filters, plus the hand-made and searched teaching gardens and the contrasting pairs; each world’s main path is the template (introduction, two applications, trap, combinations, calm garden, capstone; roles assigned by measurement; 72 gardens in all), and the rest of its gardens are optional extras (76); writesout/campaign.jsonandout/campaign_stats.json(per-spec candidates and the pairs are cached inout/campaign_cache/).src/pairs.py: searches for the contrasting pairs that introduce each rule (the same small garden before and after one change: a ripple stone, a band or the wide rake that breaks every par raking of the first; one groove that leaves one raking; pebbles in place of grooves; a side gate that leaves one of two wide-rake rakings).src/exp_continuity.py: how many of the par rakings rewarded in worlds 1–3 turn back in open sand (writesout/continuity.json).src/test_side_pivot.py: rechecks the side gate’s turn counting and finished sand (no pivot) in the C solver against brute force (small gardens) and CP-SAT (medium); logs inout/test_side_pivot_seed*.log.src/hints.py: the abbot’s first observation for generated gardens: the provable fact that most changes the plan (free gardens: true of every par raking, and missed by the most near-misses; strict gardens: about the unique raking, and lowering the grade the most), with the cells the abbot points at; trap scores for the template’s ordering.src/export_proto.py: exports the campaign toout/proto_gardens.json, re-deriving each garden’s raking count (exact solver), par (CP-SAT), every par raking (for the in-page hints), and an intended raking (asserted valid and at par), with its first hint (authored, or fromhints.py), its role, whether it is an extra, its pair, and whether the monk demonstrates it first; writes the hints’ measured effect toout/hint_stats.json.src/seed_garden_names.py,out/garden_names.json: garden names follow layouts (a registry keyed by the garden’s rows, gates and clues), so a garden keeps its name, and its poem, when the campaign is regenerated or reordered.poems/samon-poems.json: the poems, one per garden and one for the practice garden: Japanese text, working translation (and its credit), poet, collection, the page each text was checked against, notes, the short source shown in the game, and why each poem goes with its garden.poems/astra-samon-poem-selection.jsonis GPT-6 Astra’s original selection for the 136-garden campaign (with its unused candidates);poems/pass2_replacements.pyandpoems/pass3_astra_review.pyare the second and third curation passes (25 swaps and 4 translation repairs, logged in the JSON’sabout.revisions);poems/originals-brainstorm.mdholds original candidates for three gardens, not used.src/poems.pyattaches the poems to the gardens.src/daily.py: a year of daily gardens (out/daily_gardens.json, 366; stats inout/daily_stats.json): the campaign’s unused candidates plus fresh ones from its generators (cached inout/daily_cache/), none sharing a layout with the campaign, exported like the campaign’s gardens; the page picks the day’s by the local date.sound/: the recordings carried in the page (tracks.json: credits and sources;make_sound_bundle.py: loudness measurement, static gain, mono MP3, writesout/sound_pieces.json;README.md: how the sounds work and how a SingleFile/gwtar step can move the recordings out of the page).src/sound_pieces.pywrites them into the page as data: URLs for both builds. The drips, pipe, wind, birds, crickets, footsteps, rake scrape, bell and seal are synthesized in the prototype itself.proto/samon.template.html,proto/build.py: the prototype (one page; canvas; no dependencies, no requests).build.pyembeds the gardens, their poems, the daily year and the recordings, and writessamon.html(a page body, for publishing as an artifact) andsamon.standalone.html(a complete HTML document, with the social-media<meta>tags, for self-hosting). Sharing lives in the template too: a raking is a code in the URL’s hash (#r=: the walk as a number in the mixed radix of its legal moves, 8–17 base64url characters;walkEncode/walkDecode,rakingCode/readRaking), shown but not credited to whoever opens it; all progress is another (#p=:progressCode/readProgress/importProgress, offered and merged); gardens are named in both by a 32-bit hash of their layout (layoutHash/byLayout), never by index, so links survive a reordering and a changed garden is refused rather than mistaken;#dailyopens today’s garden; the share text draws the raking in box-drawing characters (gridText).proto/test/wrap.py,proto/test/run.py: Playwright test; the first visit (the monk’s demonstration, which plays once, resets the garden, and is replayed by Watch or skipped by a tap; then the waiting monk and the first-drag hint); draws every garden’s intended raking with a real mouse, lets go, and checks that it is raked at par and sealed; walking back after raking, the keyboard’s beat, and a complete walk that may not end where it does; refusals (wide rake, pebbles); Restore; the abbot’s verdicts and rewinds; the grey seal (and that Step back and Restore keep it; that inconclusive answers and the reason a walk stopped are free); unlocking by the main path, extras (folded behind “+N more”, unfolding while one is open; locked worlds as one line), and Next; for every garden, that the bends drawn in the raked stroke = the turns counted = par; that finished sand is never a pivot; that a dead end is explained by the rule that actually fails; assists; the practice garden; the poems (every garden has one; it follows any raking, helped or off par; the next garden fades in after it; a tap stays; ‘original’ shows the Japanese; none during a demonstration); the scroll of poems earned; the daily garden (picked by date, Rake commits, the record and streak survive a reload, the next day is fresh); drag intent (a hand drifting off the row still draws the row; the grip survives leaving the field; arcs, rounded corners, reversals and wobbles); sound (no context before a gesture; each synthesized sound renders audibly; Sound off persists; the recording is found, credited with its source, put down to follow a world’s bell, heard, and let go; the scrape, footsteps, birds, crickets and wind render; the scrape runs with the raking; day and night by the clock; volume and haptics settings remembered, in the panel under the speaker’s chevron; the speaker in the header and M mute; a browser that will not play is shown; a tap on the poem goes on and a tap beside it stays); sharing (every intended raking round-trips through its code; an illegal walk has none; the share text’s heading, drawing and link; a#r=link shows the raking with its seal, credited to nobody, and a tap or Rake it yourself gives the viewer the garden fresh with nothing to Restore; the grey seal travels; a daily garden’s raking from another day sends the viewer on to today’s; Share this raking copies text and link, and the link shows it;#p=is offered, then merged with better-state-wins, carrying the day’s record and an unfinished walk with its help; the daily share carries shape and score but no stroke;#daily; bad codes are refused with a word; every garden’s layout hash is distinct; a tampered hash is refused; links made before a reordering of the campaign still open, and credit, the right garden; every control has a tooltip); screenshots in light and dark. Every garden’s intended raking is stored inout/proto_gardens.json, so nothing is solved at test time; the minutes go on real mouse drags and real-time reveals.SAMON_QUICK=1(about 3 minutes) drags three gardens instead of 148 and samples the abbot’s explanations on 30 random routes per garden instead of 400.proto/test/fuzz.py: a gesture fuzzer: random drags, taps, keys, buttons, pips, pills and reloads against the page’s invariants (a legal walk, consistent mode and controls, bracketed status, bounded sound timers, progress that parses and a draft that survives a reload; no errors).python3 proto/test/fuzz.py [seconds] [seed]. It found two bugs (a draft lost to a page closed within the save’s debounce; the page returning to the first unraked garden instead of the one open), so it stays.proto/test/thumbnail.py: renders the social-media thumbnail (deliver/2026-10-05-gwern-samon-thumbnail.png, 1200×630: a garden raked at par beside the title);thumbnail.py pickrenders candidates.proto/test/gt_check.py: checks the page’s hint search (can still reach par / can still be finished / nearest completable footprint) against the C solver on random routes; writesout/hint_check.json(gt_check.py 15: 30 routes per garden, the 4,440 routes quoted in the document).standalone/build_standalone.py: builds the single-file edition,standalone/2026-10-03-gwern-samon.html: the design document converted fromsamon.mdby Pandoc (figures fromfig/final/embedded as SVG data URIs; Wikipedia shorthand resolved) with the playable prototype in the same page, an appendix holding this tree (code shown; every file in an embedded .zip, also written tostandalone/2026-10-03-gwern-samon-code.zip), and no external resources.standalone/test_standalone.pychecks that the page makes no requests, that figures, contents and collapsed sections work, that the appendix’s files and its .zip match this tree and the download delivers the .zip, and runs the prototype tests (it is generated fromproto/test/run.pybystandalone/make_test_standalone.py).samon.md: the design document (Gwerndown).fig/final/: its figures (PNG and SVG).out/: saved levels, picks, and experiment outputs.
The guide and the document
README.md
# Samon: solver, experiment, and prototype code
Code behind the numbers and figures in the _Samon_ design document (2026-10-03, revised 2026-10-04), and the playable prototype.
The single-file edition of the document carries this whole tree in its appendix: the code is shown there, and every file is in the .zip it offers (all but the three generated pages, which the build scripts write).
## Build
cd src && gcc -O3 -march=native -Wall -Wextra -o zgsolve zgsolve.c
pip install ortools cairosvg playwright # CP-SAT optimiser; SVG→PNG for figures; browser test of the prototype
playwright install chromium
pip install beautifulsoup4 # (and pandoc) for the single-file edition
# ffmpeg with libmp3lame, for sound/make_sound_bundle.py (the recordings; see sound/README.md)
Paths: the scripts use absolute paths under `/home/claude/zen/` (the directory this tree was developed in).
To run from wherever this tree is unpacked, rewrite them once, from the top of the tree:
grep --recursive --files-with-matches --exclude=README.md /home/claude/zen . | xargs sed --in-place "s|/home/claude/zen|$PWD|g"
## Files
- `src/zgsolve.c`: exact DFS solver (counts/enumerates rakings; turn & U-turn histograms; branch-and-bound min turns).
Rules: one stroke ending at the gate; optional ripple rings, wave bands / ichimatsu, given grooves,
pebbles (`shape`: S = straight through, B = turn), no-U-turn (`nohairpin 1`), wide rake (`nohairpin 2` + `endcells`),
fixed rake start (two gates: `end`, and `endside`, the wall the side gate is in, so that a bend in its cell counts as a
turn), the abbot's finished sand (`R`: not walkable, and not a pivot for the wide rake). `stream 1` prints every
solution (used to export all par rakings).
- `src/zg.py`: garden model (text symbols: `.` sand, `#` stone, `O` ripple stone, `h`/`v` wave bands,
`=`/`|` ichimatsu blocks; `clues` = grooves, `marks` = pebbles), solver driver, rule checker, brute-force enumerator.
- `src/zcp.py`: independent CP-SAT model (AddCircuit + dummy node) for par proofs and cross-checks.
- `src/grader.py`: tiered deductive solver used to grade difficulty.
- `src/gen.py`, `clues.py`, `levels.py`, `search*.py`: layout sampling, greedy groove and pebble selection, level searches.
- `src/exp_marks.py`, `fig_pebbles.py`: pebbles vs grooves on the same gardens (writes `out/marks_vs_grooves.json`), and the pebbles figure.
- `src/render.py`, `final_figs.py`: figure rendering (writes `fig/final/*.png|svg`, `out/facts.json`).
- `src/final_tables.py`, `exp_*.py`: the tables and statistics quoted in the document.
- `src/verify_doc.py`: re-derives every number quoted in the document (C solver + CP-SAT); prints OK/BAD.
- `src/test_crosscheck.py`, `xc_stats.py`, `xc_cpsat*.py`: solver validation (brute force; CP-SAT; OEIS A145157/A145156).
- `src/test_marks.py`, `test_grader_marks.py`: pebbles: C solver vs brute force vs CP-SAT; soundness of the grader's pebble rules.
- `src/garden_sampler.py`: the garden-like stone sampler (odd-numbered, asymmetric groups of one- to three-cell stones;
ripple stones, wave bands, closed rings, side gates, ichimatsu layouts). `src/exp_sampler.py` compares its yields
with single-cell stones (writes `out/sampler_yields.json`).
- `src/campaign.py`: generates the campaign (7 worlds, 148 gardens): generated gardens per world from the sampler and
the pipeline's filters, plus the hand-made and searched teaching gardens and the contrasting pairs; each world's main
path is the template (introduction, two applications, trap, combinations, calm garden, capstone; roles assigned by
measurement; 72 gardens in all), and the rest of its gardens are optional extras (76); writes `out/campaign.json` and
`out/campaign_stats.json` (per-spec candidates and the pairs are cached in `out/campaign_cache/`).
- `src/pairs.py`: searches for the contrasting pairs that introduce each rule (the same small garden before and after one
change: a ripple stone, a band or the wide rake that breaks every par raking of the first; one groove that leaves one
raking; pebbles in place of grooves; a side gate that leaves one of two wide-rake rakings).
- `src/exp_continuity.py`: how many of the par rakings rewarded in worlds 1--3 turn back in open sand (writes `out/continuity.json`).
- `src/test_side_pivot.py`: rechecks the side gate's turn counting and finished sand (no pivot) in the C solver against
brute force (small gardens) and CP-SAT (medium); logs in `out/test_side_pivot_seed*.log`.
- `src/hints.py`: the abbot's first observation for generated gardens: the provable fact that most changes the plan
(free gardens: true of every par raking, and missed by the most near-misses; strict gardens: about the unique raking,
and lowering the grade the most), with the cells the abbot points at; trap scores for the template's ordering.
- `src/export_proto.py`: exports the campaign to `out/proto_gardens.json`, re-deriving each garden's
raking count (exact solver), par (CP-SAT), every par raking (for the in-page hints), and an intended raking
(asserted valid and at par), with its first hint (authored, or from `hints.py`), its role, whether it is an extra, its
pair, and whether the monk demonstrates it first; writes the hints' measured effect to `out/hint_stats.json`.
- `src/seed_garden_names.py`, `out/garden_names.json`: garden names follow layouts (a registry keyed by the garden's rows,
gates and clues), so a garden keeps its name, and its poem, when the campaign is regenerated or reordered.
- `poems/samon-poems.json`: the poems, one per garden and one for the practice garden: Japanese text, working
translation (and its credit), poet, collection, the page each text was checked against, notes, the short source shown
in the game, and why each poem goes with its garden. `poems/astra-samon-poem-selection.json` is GPT-6 Astra's original
selection for the 136-garden campaign (with its unused candidates); `poems/pass2_replacements.py` and `poems/pass3_astra_review.py` are
the second and third curation passes (25 swaps and 4 translation repairs, logged in the JSON's `about.revisions`); `poems/originals-brainstorm.md` holds original
candidates for three gardens, not used. `src/poems.py` attaches the poems to the gardens.
- `src/daily.py`: a year of daily gardens (`out/daily_gardens.json`, 366; stats in `out/daily_stats.json`): the campaign's
unused candidates plus fresh ones from its generators (cached in `out/daily_cache/`), none sharing a layout with the
campaign, exported like the campaign's gardens; the page picks the day's by the local date.
- `sound/`: the recordings carried in the page (`tracks.json`: credits and sources; `make_sound_bundle.py`: loudness
measurement, static gain, mono MP3, writes `out/sound_pieces.json`; `README.md`: how the sounds work and how a
SingleFile/gwtar step can move the recordings out of the page). `src/sound_pieces.py` writes them into the page as
data: URLs for both builds. The drips, pipe, wind, birds, crickets, footsteps, rake scrape, bell and seal are
synthesized in the prototype itself.
- `proto/samon.template.html`, `proto/build.py`: the prototype (one page; canvas; no dependencies, no requests).
`build.py` embeds the gardens, their poems, the daily year and the recordings, and writes `samon.html` (a page body, for publishing as an artifact) and
`samon.standalone.html` (a complete HTML document, with the social-media `<meta>` tags, for self-hosting). Sharing lives in
the template too: a raking is a code in the URL's hash (`#r=`: the walk as a number in the mixed radix of its legal moves,
8–17 base64url characters; `walkEncode`/`walkDecode`, `rakingCode`/`readRaking`), shown but not credited to whoever opens
it; all progress is another (`#p=`: `progressCode`/`readProgress`/`importProgress`, offered and merged); gardens are
named in both by a 32-bit hash of their layout (`layoutHash`/`byLayout`), never by index, so links survive a reordering
and a changed garden is refused rather than mistaken; `#daily` opens today's garden; the share text draws the raking in
box-drawing characters (`gridText`).
- `proto/test/wrap.py`, `proto/test/run.py`: Playwright test; the first visit (the monk's demonstration, which plays once,
resets the garden, and is replayed by Watch or skipped by a tap; then the waiting monk and the first-drag hint);
draws every garden's intended raking with a real mouse, lets go, and checks that it is raked at par and sealed; walking back
after raking, the keyboard's beat, and a complete walk that may not end where it does; refusals (wide rake, pebbles); Restore; the abbot's verdicts and rewinds;
the grey seal (and that Step back and Restore keep it; that inconclusive answers and the reason a walk stopped are free);
unlocking by the main path, extras (folded behind "+N more", unfolding while one is open; locked worlds as one line), and Next; for every garden, that the bends drawn in the raked stroke = the turns
counted = par; that finished sand is never a pivot; that a dead end is explained by the rule that actually fails;
assists; the practice garden; the poems (every garden has one; it follows any raking, helped or off par; the next
garden fades in after it; a tap stays; 'original' shows the Japanese; none during a demonstration); the scroll of poems earned;
the daily garden (picked by date, Rake commits, the record and streak survive a reload, the next day is fresh); drag
intent (a hand drifting off the row still draws the row; the grip survives leaving the field; arcs, rounded corners,
reversals and wobbles); sound (no context before a gesture; each synthesized sound renders audibly; Sound off persists;
the recording is found, credited with its source, put down to follow a world's bell, heard, and let go; the scrape,
footsteps, birds, crickets and wind render; the scrape runs with the raking; day and night by the clock; volume and
haptics settings remembered, in the panel under the speaker's chevron; the speaker in the header and M mute; a browser that will not play is shown; a tap on the
poem goes on and a tap beside it stays); sharing (every intended raking round-trips through its code; an illegal walk
has none; the share text's heading, drawing and link; a `#r=` link shows the raking with its seal, credited to nobody,
and a tap or Rake it yourself gives the viewer the garden fresh with nothing to Restore; the grey seal travels; a daily
garden's raking from another day sends the viewer on to today's; Share this raking copies text and link, and the link
shows it; `#p=` is offered, then merged with better-state-wins, carrying the day's record and an unfinished walk with
its help; the daily share carries shape and score but no stroke; `#daily`; bad codes are refused with a word; every
garden's layout hash is distinct; a tampered hash is refused; links made before a reordering of the campaign still
open, and credit, the right garden; every control has a tooltip); screenshots in light and dark. Every garden's intended raking is stored in `out/proto_gardens.json`, so nothing is solved at test time; the
minutes go on real mouse drags and real-time reveals. `SAMON_QUICK=1` (about 3 minutes) drags three gardens instead of
148 and samples the abbot's explanations on 30 random routes per garden instead of 400.
- `proto/test/fuzz.py`: a gesture fuzzer: random drags, taps, keys, buttons, pips, pills and reloads against the page's
invariants (a legal walk, consistent mode and controls, bracketed status, bounded sound timers, progress that parses and
a draft that survives a reload; no errors). `python3 proto/test/fuzz.py [seconds] [seed]`. It found two bugs (a draft lost
to a page closed within the save's debounce; the page returning to the first unraked garden instead of the one open), so it stays.
- `proto/test/thumbnail.py`: renders the social-media thumbnail (`deliver/2026-10-05-gwern-samon-thumbnail.png`, 1200×630:
a garden raked at par beside the title); `thumbnail.py pick` renders candidates.
- `proto/test/gt_check.py`: checks the page's hint search (can still reach par / can still be finished / nearest
completable footprint) against the C solver on random routes; writes `out/hint_check.json` (`gt_check.py 15`: 30 routes per
garden, the 4,440 routes quoted in the document).
- `standalone/build_standalone.py`: builds the single-file edition, `standalone/2026-10-03-gwern-samon.html`: the design
document converted from `samon.md` by Pandoc (figures from `fig/final/` embedded as SVG data URIs; Wikipedia shorthand
resolved) with the playable prototype in the same page, an appendix holding this tree (code shown; every file in an
embedded .zip, also written to `standalone/2026-10-03-gwern-samon-code.zip`), and no external resources.
`standalone/test_standalone.py` checks that the page makes no requests, that figures, contents and collapsed sections
work, that the appendix's files and its .zip match this tree and the download delivers the .zip, and runs the
prototype tests (it is generated from `proto/test/run.py` by `standalone/make_test_standalone.py`).
- `samon.md`: the design document (Gwerndown). `fig/final/`: its figures (PNG and SVG).
- `out/`: saved levels, picks, and experiment outputs.
samon.md
---
title: "_Samon_: Designing a Zen Garden Raking Puzzle"
author: Gwern, Claude-5.5-opus
description: "Full design for a one-stroke sand-raking puzzle game: a novice monk walks into a dry garden and must rake his way back out to the gate. Solver experiments show the bare rule is loose (1.5 million rakings of a 7×7 garden), but scoring by turns recovers the traditional ripple and wave patterns, and a 'wide rake' rule forbidding U-turns in open sand yields unique, garden-like solutions. Covers rules, mechanics, progression, level generation & grading, interface (including hints that are proofs about the player's own route), presentation, a production plan, and prior art, with a playable prototype."
thumbnail: /doc/design/2026-10-05-gwern-samon-thumbnail.png
thumbnail-text: "The prototype's garden 'Ripples' raked at par: the stroke's grooves ring a stone in square waves, with the abbot's vermilion seal in the corner; beside it the title Samon 砂紋 and the rule in one sentence."
created: 2023-02-13
modified: 2026-10-05
status: in progress
confidence: likely
importance: 3
css-extension: dropcaps-kanzlei
...
<!-- LLM META (handoff note; ≤20 lines)
Provenance: expanded by Claude-5.5-opus (configured model id claude-opus-5-5) on 2026-10-03 from Gwern's 2023-02-13 note (the first paragraph and the margin-note voice are his).
All counts, pars, grades & yields come from the attached solver code (zgsolve.c exact DFS; zcp.py CP-SAT; grader.py) and are reproducible from final_figs.py / final_tables.py / exp_*.py.
Solver validated against OEIS A145157 & A145156, an independent brute-force enumerator (0 mismatches in ≈340,000 random small gardens, ≈67,000 solvable, every rule), and the CP-SAT model.
Playtesting: only Gwern's sessions and one outside player's note so far. All difficulty claims are from a mechanical grader, not humans. Treat 'tier' as a proxy.
Prototype: proto/samon.html (artifact page body) & proto/samon.standalone.html, built by proto/build.py from out/proto_gardens.json (src/export_proto.py); proto/test/run.py draws each garden's intended raking with a real mouse and checks par & seal; proto/test/gt_check.py checks the in-page hint search against the C solver.
Revision (same day): merged the best of a parallel design by GPT-6 Astra ("The Last Footprint", supplied by Gwern): proof-backed hints on the player's route, restorable rewinds, explicit Rake commit, practice garden, 'any turn' clue (here pebbles, plus 'straight'), world template, slice plan with pass/fail criteria, symmetry proof. Credited in Methods; pebble numbers from exp_marks.py / fig_pebbles.py.
After Gwern's first play session: helped rakings earn a grey seal; the reveal is now a continuous rake over a lit heightfield (it was cell-by-cell line art).
After the second play session: no Rake button (letting go of a complete walk rakes it; a tap on the raked garden walks back); the monk waits in the gateway, with a beckoning ring and, until the first walk, a ghost fingertip showing the drag (the gate was a dashed circle).
Decisions after the open-questions list: scoring rules kept (gate turn counts; braced U-turns not free); worlds open at two-thirds raked, any turns; the seal unchanged (redundant but harmless in strict gardens); title Samon; worlds ordered by the template (roles computed in src/campaign.py); hints chosen as the plan-changing fact (src/hints.py), with the abbot pointing at its cells.
Campaign: all 148 gardens are in the prototype (src/campaign.py): 34 teaching gardens (13 of them contrasting pairs, src/pairs.py) + 114 generated with garden-like stones (src/garden_sampler.py; yields vs single-cell stones from src/exp_sampler.py); 72 on the main path, 76 extras; first hints for generated gardens are solver facts (src/hints.py). Not curated, not playtested. The in-page hint search memoizes dead positions and finds rewinds by binary search (proof of monotonicity in Methods).
After Astra's review of the prototype (2026-10-04): fixed side-gate turn counting and finished sand as a wide-rake pivot in all solvers (src/test_side_pivot.py), the grey-seal hole, the free inconclusive hint, the dead-end explanation order; short main path + extras; contrasting pairs; continuity claim weakened with numbers (src/exp_continuity.py). At Gwern's request: the monk demonstrates each new rule once, then resets the garden (Watch replays). After an outside player's note (phone; no sense of the potential until garden 6, then the first real garden): it is now third, and the 2×3 garden is gone.
Rendering fixes after Gwern's look (2026-10-04): the stroke starts flush with the back of its first cell (was a quarter-cell in); the poem sits on a plate (frosted then; a white card with a hairline since the gwern.net restyle of 2026-10-05, which made the whole page monochrome: proto/samon.template.html's stylesheet and the single-file edition's PAGE_CSS); stones of several cells fill their cells (lumpy superellipses) instead of floating in bare sand; sand grain is procedural (two height octaves + albedo fleck and mottle), seeded afresh per layout, field up to 144 samples/cell.
Then (at Gwern's request): the scroll of poems earned (enterPoems in the template) and the daily garden (src/daily.py → out/daily_gardens.json, 366 gardens embedded; the page appends today's as a slot after the campaign, keyed daily:YYYY-MM-DD in progress; Rake commits; streak from consecutive keys; tests in run.py section 11). Drag intent (after Gwern's note on clunky mouse control): the walk follows the axis of hand motion with 30° hysteresis (dragTo/dragAxis/settle in the template), not the cell under the pointer, and pointer capture plus clamping keep the grip beyond the walls; tests in run.py section 12. Sound (at Gwern's request; the rake's scrape dialled down 15% at his ear, SCRAPE_LEVEL): drip, pipe, bonshō and tok synthesized in Web Audio (SND in the template; renderSound renders them offline for tests); the shakuhachi recording (sound/tracks.json → sound/make_sound_bundle.py → out/sound_pieces.json) is carried in the page as a data: URL in an inert script.piece, which Gwern's SingleFile/gwtar step can move into an archive (samonAsset in the template resolves data: URLs, plain paths, or gwtar members); tests in run.py section 13. Then the rest of the sound: the rake's scrape driven by revealTick's speed (scrapeStart/Set/Stop), footsteps (crunch) on every step, wind bed and gusts, birds by day and crickets by night (dayPart, by the local clock), a volume slider and a haptics switch (navigator.vibrate, 8 ms per cell); then a mute button in the header (M), navigator.audioSession = ambient, Media Session pause/stop, and a blocked state read from the context (soundBlocked), after Gwern asked for mute controls and whatever browser silence preferences exist (there is no CSS one). Poem taps remapped after dbohdan's note. Astra's second review (2026-10-05): focus returns to the canvas after an abbot action and rewinds restate what holds (addAction/rewindTo); stones repainted as sumi-e (contour/contourPath/paintStone in the template: lumpy contours, shadowBlur contact shadows, gradient wash, one variable-width ink band with dry breaks); the monk is an ensō (makeEnso), at Gwern's suggestion, then rubricated (vermilion) in place of the gate triangle, which he found redundant; both smoothed after his look (no per-segment strokes, no stepped fills). Editorial brackets (gwern.net's convention) on everything the game says to the player: setStatus/appendStatus in the template, the help, lock notes, the scroll's notes and credits, the single-file edition's own messages. Sharing (at Gwern's request, 2026-10-05, to make the one page travel): a raking is a code in the URL's hash (#r=), the walk written as a number in the mixed radix of its legal moves Poem attributions are set in by an indent, with no dash and no kanji (the 原文 button is now "original"; the poet's kanji name is gone from the source line), and the beckoning ring and the ghost finger stop after three pulses (Gwern, 2026-10-05). The page below the garden slimmed at his request: locked worlds as one line, extras folded (renderNav: extrasOpen, .fold), the help a <details>, sound/volume/haptics in a panel under the speaker's chevron (#soundMore/#soundPanel), the rule box only for rule-bearing gardens with the raking counts in Par's tooltip, no Sand counter; assists stay in view. (walkEncode/walkDecode in the template: 8–17 base64url characters for a whole raking, a mean of 22 bits against 86 at two bits a step), shown to whoever opens it but credited to nobody; all progress as another code (#p=, progressCode/readProgress/importProgress, offered and merged with whichever is better); gardens are named in both codes by a 32-bit layout hash (layoutHash/byLayout), never by index, after Astra's review of the first version (8-bit check, index-keyed progress); #daily opens today's garden; the share text draws the raking in box-drawing characters (gridText) and goes through the share sheet or to the clipboard; the daily garden shares its shape and score, never its stroke; tests in run.py section 14. Tooltips on every control, count and mark (gwern.net's habit), including the footnotes and the collapsed sections of the single-file edition.
Poems (at Gwern's request): every garden's reward is a real waka/haiku laid over the raked sand, then a fade to the next garden after ~10 s (a tap on the poem goes on, a tap beside it stays; after dbohdan's note, 2026-10-05). Selection by GPT-6 Astra for the 136-garden campaign; carried over by layout-stable names (out/garden_names.json), 13 new pairings, two Shiki poems whose texts could not be confirmed replaced; all 149 Japanese texts re-checked against linked pages; a second pass swapped 18 weak pairings for better classical poems (poems/pass2_replacements.py), and Astra's review of it 7 more plus 4 translation repairs (poems/pass3_astra_review.py; its two originals declined); log in the JSON's about.revisions; 25 translations revised, 17 new. Data: poems/samon-poems.json (src/poems.py).
Image paths under /doc/design/ are suggestions; files are named per the MoS. Figures are programmatic renders (no generative-image model).
Open decisions flagged inline as TODO.
-->
<div class="abstract">
Raking a Zen dry garden is a chore with the shape of a puzzle: a novice who walks into the sand leaves footprints, and must rake his way back out to the gate without crossing his own lines or raking anything twice.
We design _Samon_ ("sand patterns"), a puzzle game built on that rule: the player draws one stroke from the gate covering every cell of sand; the monk walks it in, sets down the rake, and walks backwards out, revealing the pattern.
We formalize gardens as grid graphs and rakings as Hamiltonian paths ending at the gate, show that "walk in, rake out" and the simpler "mirror raking" are exactly equivalent (so the interface can be "draw from the gate"), and test candidate mechanics on thousands of random gardens with an exact solver, a constraint-programming optimizer, and a deductive difficulty grader.
The bare rule is loose: an empty 7×7 garden with a corner gate has 1,510,446 valid rakings, and random stone layouts are mostly either unsolvable (often by checkerboard parity) or wide open.
Scoring by turns fixes the slack without new rules: in that 7×7 garden only 66 rakings reach the minimum ("par") of 12 turns, and par rakings in lightly-stoned gardens tend to be the traditional motifs of parallel lines and rings around stones.
A single physical rule, the **wide rake** (no U-turn in open sand; set the rake down against a stone or wall), collapses gardens to a handful of rakings, often exactly one, and in about half of the calm-looking unique gardens that one is also a par raking of the unrestricted garden: in one 8×8 garden it picks out the only U-turn-free raking among 12 par rakings of 6,310,226.
Ripple stones and wave bands, the obvious "pattern" constraints, add texture but not tightness; given grooves pin down unique answers with ~5--8 clues, and pebbles that say only "straight here" or "turn here" need more clues (a median of 11 against 7) but make nearly every garden a lookahead puzzle.
The resulting design has 7 worlds, each adding one rule (shown by a before-and-after pair of small gardens, the second raked once by the monk as a demonstration); free gardens scored against a proven par with [_Opus Magnum_](!W)-style histograms; strict gardens with unique, graceful answers; hints that are proofs about the player's own route (it can still reach par; it can still be finished; or it cannot, and here is the nearest footprint to step back to); a daily garden that can be raked only once; a classical poem as the reward for every raked garden, after which the next garden fades in; a heightfield sand renderer and a free-raking practice garden; a level pipeline whose yields and difficulty distribution we measure; and a production plan with pass/fail criteria.
A playable prototype implements most of it, with the whole campaign of 148 gardens and their poems.
</div>
Zen sand gardens are often intended for meditation, whether to look at or while maintaining it by raking the sand into the prescribed patterns which are formed by the tines of the rake yielding parallel lines (like `|||`).
Such a chore might be tedious, but there is also a great deal of satisfaction to restoring an esthetic order from chaos, and raking sand seems like one of those things, like popping bubblewrap, which can be oddly satisfying---I recall the lifeguards at my summer camp seeming to rather enjoy the task of raking the beach in front of their office every day and not allowing any campers to do so.
Puzzle-solving is also often described as being 'meditative' and 'oddly satisfying', even when it looks a lot like a tedious chore.
So for those of us who do not live at a Zen temple, can we make a Zen sand garden *puzzle*?
This page turns the original 2023 sketch into a full design.
Where the sketch speculated (would "mirror raking" be too restrictive? could a neural net rank solutions by beauty? how should difficulty be raised?), we answer with a solver and measurements; where it left gaps (what exactly makes a garden hard, what the worlds are, how levels are generated, what the player touches), we fill them.
The title is _Samon_ (砂紋, "sand crest"), the term for the patterns themselves.[^samon]
[^samon]: [JAANUS](https://projects.mcah.columbia.edu/jaanus/node/1584 "'samon 砂紋', JAANUS"): "The technique of making a pattern or design in sand or gravel... Typically in such gardens, the gravel is raked into a series of ridges or ripples which suggest waves in an ocean, but may well be pure abstraction." Garden associations list ~7 standard patterns, including _sazanami_ (continuous ripples), _mizumon_ (concentric rings), _ryūsui_ (flowing water) and _ichimatsu_ (checkerboard) ([NAJGA 2023](https://najga.org/7-common-raking-patterns-in-japanese-dry-landscape-gardens/ "'7 Common Raking Patterns In Japanese Dry Landscape Gardens', NAJGA 2023")).
# The Garden
[Pieces.]{.margin-note} A garden is a walled rectangle of white gravel---in Kyoto, typically _Shirakawa-suna_, weathered granite from the Kitashirakawa area ([Nabunken](https://www.nabunken.go.jp/org/bunka/jgd/pages/ShirakawaSand.html "'Shirakawa sand', Nara National Research Institute for Cultural Properties"))---divided into square cells, with stones as obstacles.
One cell on the veranda side is the **gate**: the gap in front of the _kutsunugi-ishi_, the flat stone below the veranda where one removes one's shoes ([JAANUS](https://projects.mcah.columbia.edu/jaanus/node/2714 "'kutsunugi-ishi 沓脱石', JAANUS")).
The player is an _[unsui](!W)_, a novice monk, and the garden is viewed, as at [Ryōan-ji](!W), from the abbot's veranda: the bottom edge of the screen.
[The rule.]{.margin-note} The novice steps off the shoe-stone, walks into the sand, puts down his rake, and walks *backwards* to the gate, dragging the rake over his own footprints.
The garden is finished when every cell of sand has been raked exactly once, the lines are unbroken, and he is back on the shoe-stone.
That is the whole rule: one stroke, every cell once, no crossing, ending at the gate.
{.width-full}
[Formally: Hamiltonian paths.]{.margin-note} Formally, the sand cells form a grid graph, and a valid raking is a [Hamiltonian path](!W) of that graph which ends at the gate cell.
The rake lines are the path's shape: a cell traversed straight gets parallel grooves; a cell where the path turns gets concentric quarter-circles; the cell where the rake was set down gets the groove's start.
Every claim below about counts, pars, and difficulty comes from an exact solver for this formulation (see [Methods](#methods)).
[Walking in is free.]{.margin-note} The original sketch distinguished the forward walk (leaving footprints) from the backward raking, and proposed "mirror raking"---the monk retraces his walk exactly---as a possibly-too-restrictive simplification.
It is not a restriction at all.
Any walk in, followed by a raking that covers every cell exactly once and ends at the gate, erases every footprint, because every cell gets raked; so the walk in is irrelevant except for where it ends, and the set of finished gardens is exactly the set of Hamiltonian paths ending at the gate.
Conversely, every such path is achieved by walking it in and raking it out.[^mirror-proof]
So mirror raking loses nothing, and the interface can be the simplest possible: *draw a path from the gate*; that path is the walk in; the raking is its reverse.
[^mirror-proof]: Proof sketch: let the walk in be any walk _W_ from the gate to cell _P_, and the raking _R_ any path from _P_ to the gate that visits every sand cell once. Every cell _W_ touched is on _R_, so all footprints are raked over; the finished pattern is _R_ alone. Hence {finished gardens} = {Hamiltonian paths ending at the gate} regardless of _W_, and choosing _W_ = reverse(_R_) shows every such path is reachable by mirror raking. The one physical constraint---never step on raked sand---holds automatically, since walking backwards the monk only steps onto cells the rake has not yet reached.
This matters for design because the story stays intact (the reveal is the monk raking himself out of the garden) while the player's task is a single, legible gesture.
# The Bare Rule Is Loose
[Too many rakings.]{.margin-note} The one-stroke rule alone is a weak constraint in open sand.
For an empty garden with a corner gate, the number of valid rakings is the number of Hamiltonian paths from a corner, [OEIS A145157](https://oeis.org/A145157 "'Number of Greek-key tours on an n X n board; i.e., self-avoiding walks on n X n grid starting in top left corner', OEIS"), which grows explosively: 824 for 5×5, 1,510,446 for 7×7, 54,986,690,944 for 9×9.
(Our solver reproduces A145157 and the 5×_n_ sequence [A145156](https://oeis.org/A145156) exactly, which is a useful correctness check.)
Anyone can rake an open garden: walk up and down in rows.
<div class="table-small">
| Garden | Valid rakings | Par (min. turns) | Rakings at par | Median turns | Max turns | Wide-rake rakings |
|:------:|--------------:|:----------------:|---------------:|:------------:|:---------:|:-----------------:|
| 3×3 | 8 | 4 | 2 | 5 | 6 | 2 |
| 4×4 | 52 | 6 | 4 | 8 | 13 | 4 |
| 5×5 | 824 | 8 | 10 | 13 | 20 | 4 |
| 6×6 | 22,144 | 10 | 24 | 20 | 31 | 6 |
| 7×7 | 1,510,446 | 12 | 66 | 27 | 42 | 6 |
| 6×8 | 899,432 | 11 | 66 | 26 | 42 | 8 |
Table: Empty gardens, gate in the bottom-left corner of the south (veranda) wall. "Turns" counts every cell where the stroke changes direction, including leaving through the gate. The last column anticipates the [wide rake](#the-wide-rake).
</div>
[Parity.]{.margin-note} Stones make the rule bite, but mostly by making gardens *impossible*.
Color the cells like a checkerboard: every step of the stroke changes color, so a stroke over _n_ cells alternates colors, and if one color has more cells, the stroke must begin and end on it.
A 5×5 garden has 13 dark and 12 light cells, so its gate must be on a dark cell; move the gate one cell along the wall and there is no raking at all.
Of random 7×7 gardens with a gate somewhere on the veranda wall, 57% pass this parity test with no stones and only 31% with 6 stones.

[Random stones: unsolvable or open.]{.margin-note} Past parity, stones create dead ends (a cell with one free neighbor must be where the rake is set down---at most one such cell may exist) and corridors (a cell with two free neighbors must use both).
These are the classic deductions of one-stroke puzzles, and they are real, but random stone placement produces them in the wrong proportion: gardens are either unsolvable or still have thousands of rakings.
In 300 random 7×7 gardens per row, *none* with 0--6 random stones had a unique raking:
<div class="table-small">
| Stones | Parity OK | One stroke: solvable | One stroke: unique | One stroke: median rakings | No U-turns at all: solvable | Wide rake: solvable | Wide rake: unique | Wide rake: median rakings |
|:------:|:---------:|:--------------------:|:------------------:|:--------------------------:|:---------------------------:|:-------------------:|:-----------------:|:-------------------------:|
| 0 | 57% | 57% | 0% | ≥100,000 | 0% | 57% | 0% | 6 |
| 2 | 43% | 42% | 0% | 28,326 | 0% | 18% | 2.7% | 2 |
| 4 | 34% | 20% | 0% | 610 | 0% | 2.7% | 0.3% | 3 |
| 6 | 31% | 8% | 0% | 96 | 0% | 0.3% | 0% | 4 |
Table: 300 random 7×7 gardens per row, random 1×1 stones, gate at a random cell of the south wall. Medians are over solvable gardens (counts capped at 100,000). The wide-rake columns are explained [below](#the-wide-rake).
</div>
The bare rule therefore cannot carry a game by itself: completion is trivial in open sand, and the obstacles that make it non-trivial mostly make it impossible.
Two things fix this: a *score* that makes the slack matter, and *rules* that remove it.
Pleasingly, both come from what makes a raked garden beautiful.
# Calm: Turns As Score
[The abbot counts turns.]{.margin-note} The commonest _samon_ are long parallel lines, interrupted where they ring a stone.
A raking that zigzags looks agitated.
So the game's single esthetic metric is the number of **turns**: every cell where the stroke changes direction.
Fewer turns is calmer.
Each garden has a **par**, the minimum number of turns over all valid rakings, *proven* by a constraint solver (in a few seconds at most, for gardens up to 9×9).
Any valid raking finishes the garden; a raking at par earns the abbot's vermilion seal.
{.width-full}
[Par is a needle.]{.margin-note} Turns make the looseness useful.
The distribution of turns over all valid rakings is a broad hump, and par sits in the far tail: in the empty 7×7 garden, 66 of 1,510,446 rakings (0.004%) reach par, and the median raking has more than twice as many turns.
Finishing a garden is easy; finishing it *calmly* is a real optimization puzzle, of the kind where intuition ("make long lines; circle the stones; use the walls") gets close and the last two turns take thought.

[Histograms.]{.margin-note} This is the structure of [Zachtronics](!W) optimization games, where after solving, the player's solution is ranked against every other player's on a few metrics ([_Opus Magnum_](!W) compares cycles, cost, and area).
_Samon_ shows one histogram per garden after completion: the player's turn count against everyone's, with par marked---a picture very like the one above, filled in by real players.
Because par is *proven*, the seal is never a trap (no "par" that nobody can reach) and never a lie (no raking can beat it).
[Par tends to look traditional.]{.margin-note} The surprise is that minimizing a single number recovers the traditional motifs.
In the open and lightly-stoned gardens we inspected, par rakings were nested U-bends and spirals: long parallel lines across the garden (_sazanami_), and concentric rings around stones (_mizumon_), without either being specified.
The rings appear because circling a stone is the cheapest way to use the cells next to it; the parallel lines appear because every turn costs.
This is not a law, though: with crowded or awkwardly-placed stones, par rakings degenerate into meanders full of tight U-turns in open sand.
Among 60 random solvable gardens (6×7--7×8, 1--3 stones), only 40% admitted *any* raking free of open-sand U-turns, and only 20% had a par raking that was free of them; so "par is beautiful" is a property for the level curator to select for (and we do: see [Generating Gardens](#generating-gardens)), not something the rule guarantees.
[Why not a neural esthetic score?]{.margin-note} The sketch proposed ranking solutions with a learned esthetic model, such as the CLIP-based predictor behind [LAION-Aesthetics](https://laion.ai/blog/laion-aesthetics/).
For *scoring the player*, a hand-countable metric is better: the player can see it, count it, and deliberately optimize it, and a proven optimum exists; an opaque neural score can be none of these, and would invite adversarial raking.
The learned model is better spent upstream, ranking *layouts* (which stone arrangements look like real gardens, which par rakings are prettiest when several tie), where opacity costs nothing.
<!-- TODO: test whether a CLIP-aesthetic score of renders agrees with turn count among rakings of one garden; untested here (no model weights available offline). -->
# Mechanics
[One rule per world.]{.margin-note} The standing criticism of [_Zen Puzzle Garden_](https://www.lexaloffle.com/zen.php "'Zen Puzzle Garden', Lexaloffle 2003"), the closest ancestor, was monotony: "there's still a persisting feeling that, even by the final few stages, _Zen Puzzle Garden_ hasn't developed one step beyond what the tutorial already demonstrated" ([Wilson 2011](https://www.pocketgamer.com/zen-puzzle-garden/review/ "'Zen Puzzle Garden Review', Wilson 2011")).
So each world adds one rule, and each rule must earn its place by changing *how the player thinks*, not just the scenery.
Every rule below is diegetic---a thing in the garden or a property of the rake---and every one was tested with the solver on random gardens before being kept.
Here are the candidates that survived, in the order the game introduces them, and then the ones that did not.
## Ripple Stones
[Rings.]{.margin-note} A **ripple stone** (marked with faint concentric rings in the abbot's sketch) must be encircled: its 8 neighboring cells must be raked as one unbroken arc, entering once and leaving once.
This is _mizumon_, the rings-around-an-island motif, as a hard constraint instead of an esthetic tendency.
[Texture, not tightness.]{.margin-note} The surprise of testing was how little it constrains.
In the garden below, the ripple rule cuts the number of valid rakings from 183,986 (with an ordinary stone) to 35,570, and leaves par unchanged at 12; 4 of the 5 par rakings already ringed the stone.
Across random 7×7 gardens with one ripple stone (and 0--2 plain stones), the median number of rakings was still 5,573.
Ripple stones teach "rings are entered from outside and left from the cell next to the entrance" and make the reveal prettier, but they cannot make a garden tight on their own.
{.width-full}
## Wave Bands
[Lines that run through.]{.margin-note} A **wave band** is a hatched region of the sketch: inside it, every row (or column, for a vertical band) must be raked as one straight, unbroken run, edge to edge.
This is _sazanami_: the long parallel ripples that most gardens are mostly made of.
Mechanically, each row of the band becomes a corridor that must be crossed in one go, so the player must plan where the turnarounds go outside the band.
[Somewhat tighter.]{.margin-note} Bands constrain more than rings: in the garden below, the band cuts 1,492,008 rakings to 2,984, and raises par from 16 to 17 (the calmest raking of the bare garden does not run its lines where the abbot wants them).
But across random 7×7 gardens with a band (and 0--2 stones), the median count was still 3,799.
{.width-full}
[Ichimatsu.]{.margin-note} Alternate horizontal and vertical bands in a checkerboard and you get _ichimatsu_.
Here the band rule finally becomes tight, because every cell now belongs to a run and the stroke can turn only at run ends, handing off from one run to the next: the 9×9 garden of nine 3×3 blocks below has exactly 2 valid rakings, versus 54,986,690,944 for the empty 9×9 garden, and the 6×6 version (four blocks) has exactly 1 for most gate positions.
These make good end-of-world showpieces: a real checkerboard _samon_, drawn in one stroke.
{.width-full}
## The Master's Grooves
[Continue his lines.]{.margin-note} The abbot has begun the raking and been called away: a few cells already carry grooves, and the finished garden must be one stroke that runs *along* them.
(Re-raking a groove in its own direction leaves no trace, so the novice may pass through these cells, but only following them.)
These are the "partial raking" of the original sketch, and are the game's general-purpose clue: unlike abstract numbers or symbols, a given groove is self-explanatory, since it *is* a piece of the answer.
[Uniqueness for ~5--8 grooves.]{.margin-note} Grooves are how free gardens become strict ones.
The level generator picks a beautiful raking as the intended answer---a par raking with no U-turn in open sand---and adds grooves from it until it is the only valid raking left.
In the garden below, 5 grooves cut 12,375 rakings to 1, and the answer is a par raking (21 turns; 1 of 11 at par).
Over 69 generated 7×7--7×8 gardens, the median was 7 grooves.
{.width-full}
[Two kinds of groove.]{.margin-note} The abbot can also leave *finished* work: a closed ring raked around a stone, as real gardens have.
A single stroke can never close a ring, so these are effectively obstacles---and a way to put the iconic closed ripple into gardens without violating the one-stroke rule.
They are also the "partial raking which makes an insoluble problem soluble" of the sketch: pre-raked cells are removed from the stroke's job, which changes the garden's topology (and, if the finished shape covers unequal numbers of dark and light cells, its parity).
## The Abbot's Pebbles
[Straight or turn, nothing more.]{.margin-note} A weaker clue than a groove: the abbot leaves a pebble on a cell.
A pale pebble means the stroke runs straight through that cell, across or down; a dark pebble means it turns there, either way.
A pebble's cell is never where the rake is set down, since that cell has only one connection.
(The "any turn" clue comes from a parallel design by GPT-6 Astra; Nikoli's [Masyu](!W) uses white and black pearls the same way, with extra conditions on the neighboring cells that pebbles do not have.)
[More clues, harder deduction.]{.margin-note} We made 88 generated 7×7--7×8 gardens unique both ways, from the same intended raking (a graceful par raking) by the same greedy procedure.
Grooves needed a median of 7 clues and pebbles 11 (more in 92% of gardens), but the pebble gardens were much harder: 92% needed lookahead by our grader (median 7 steps) and 12.5% nested lookahead, against 47% (median 2 steps; 58% in the pipeline's separate sample, [below](#generating-gardens)) and none for grooves.
A groove gives away a piece of the answer; a pebble is a disjunction to be resolved against its neighbors, which is the kind of deduction the strict worlds want.
So pebbles come after grooves in world 4, and supply the hardest strict gardens of world 7.
Below, 8 pebbles leave exactly one of a garden's 59,498 rakings; the same answer needs only 6 grooves, which make the garden tier 1, where the pebbles make it tier 3.
{.width-full}
## The Wide Rake
[No U-turns in open sand.]{.margin-note} The novice is promoted and given the senior's rake, which is too wide to pivot.
With it, the stroke may turn 90° anywhere, but it may make a tight U-turn (two consecutive turns the same way) only when the U is braced against a wall or a stone; and the rake must be set down in a cell touching a wall or a stone, never in the open.
The abbot's finished sand (his closed rings, above) is neither: turning against it would rake it.
A reversal in open sand therefore needs *something inside it*: a stone to wrap around, or a nest of wider U's around a stone.
The esthetic motivation is the same as for counting turns: a groove that doubles back on itself in open sand is the ugliest thing a raking can contain, and in real gardens lines end at walls and stones, not in open gravel.
{.width-full}
[It tightens everything.]{.margin-note} This one physical rule does what the pattern constraints could not.
In empty gardens it leaves 2--8 rakings out of up to millions (see the first table); in random 7×7 gardens with 2 stones, 18% are solvable and the median solvable garden has 2 rakings; uniqueness, which the bare rule did not produce in any of the 1,200 random 7×7 layouts, appears by itself (2.7% of 2-stone layouts).
Forbidding every U-turn, braced or not, is too much: no empty garden of any size we tried, and none of 1,200 random 7×7 gardens, could be raked at all.
[Often the calm answer.]{.margin-note} The striking result is *which* raking survives.
The 8×8 garden below has 6,310,226 valid rakings under the bare rule; 12 of them reach par (17 turns); exactly one of those 12 has no U-turn in open sand; and that one is the garden's *only* raking under the wide rake.
This is not a fluke of one garden, though it is not a law either: over 964 random gardens with a unique wide-rake raking, that raking was a par raking of the unrestricted garden in 32% of cases, and in 47% of the calm-looking ones (turn density ≤0.40) that the curator would keep.
So the novice's tool often selects the very raking the abbot would have chosen; but it is a stricter rule than his taste, not the same one (see [Free, then strict](#progression)).
{.width-full}
[Hard.]{.margin-note} Wide-rake gardens are the hardest in the game by our grader: every one we generated required lookahead (assume a move, propagate, find a contradiction), and about a quarter required *nested* lookahead.
The player-facing heuristics---"every reversal needs a pivot", "count the cells inside each U", "lines run wall to wall"---are learnable, but this needs playtesting more than anything else in the design, and is why it comes fifth rather than second.
## The Side Gate
[Both ends fixed.]{.margin-note} Some gardens have a second, side gate, and the novice must rake his way *in* from it (walking backwards from the start, so leaving no footprints at all) and out by the veranda.
Fixing both ends of the stroke doubles the force of parity (both endpoint colors are now given) and removes the player's freedom of where to begin.
The stroke comes in through the wall, so a bend in its first cell counts as a turn, as leaving by the veranda gate does.
Under the bare rule this is still loose (the garden below has 311,080 such rakings); combined with the wide rake it gives a unique, graceful answer that is also par.
{.width-full}
## Rejected Mechanics {.collapse}
<div class="abstract-collapse">
Mechanics we considered and dropped, with the reason for each.
</div>
- **Ice-sliding**: _Zen Puzzle Garden_'s rule (the monk slides until blocked, and may only turn when blocked) is the obvious alternative core. It is taken, and it makes the puzzle about stopping places rather than shapes.
- **Braced turns only** (90° turns allowed only next to walls or stones): a softer cousin of ice-sliding. Too strict: only 31% of empty 7×7 gardens remain solvable, and almost no gardens with stones.
- **No U-turns at all**: infeasible; see above.
- **Pushable lanterns, ordered leaves**: _Zen Puzzle Garden_'s later additions ([JayIsGames](https://jayisgames.com/review/zen-puzzle-garden.php "'Zen Puzzle Garden', JayIsGames")). They import Sokoban into a game whose identity is the line.
- **Repairing a disturbed garden** (a deer walked through overnight; re-rake only what you must): it sounds rich but is trivial. For the repaired garden to be one stroke, the grooves the novice leaves untouched must be an initial segment of the old stroke, ending where his new raking begins; so the cheapest repair is always to re-rake everything from the first damaged cell (in the old stroke's order) back to the gate, and the route he takes doesn't matter.
- **Multiple monks / multiple strokes / stepping stones that lift the rake**: each adds slack to a rule whose problem is already slack.
- **Bridges over a dry stream** (the only cells the stroke may cross): workable (_Flow Free: Bridges_ does it), but untested here, and it weakens "never cross your own lines", the rule that makes the fiction work.
- **Symmetric gardens** (mirror-image halves raked in mirror image): against the grain of the esthetic, which prizes _fukinsei_, asymmetry ([Japanese aesthetics](!W)), and impossible anyway. A symmetry of the garden that preserves the stroke maps the path onto itself, and a path's only symmetries are the identity and reversal; reversal would swap the gate's cell with the rake's start, so it must be the identity, which fixes every cell of the stroke: all the sand would have to lie on the mirror line (Astra's argument). *Local* symmetry, two marked patches raked in mirror image, is possible; Astra proposes it as an optional rule, untested here.
# Progression
[Seven worlds, one day.]{.margin-note} The worlds follow one day at the temple, dawn to moonrise, which gives each a lighting (and so a look: low sun makes grooves cast long shadows; noon flattens them; moonlight silvers them) without any text.
Each introduces exactly one rule; gardens grow from one-cell corridors to 9×9.
<div class="table-small">
| World | Time | New rule | What it teaches | Gardens (main path + extras) |
|-------|------|----------|-----------------|--------:|
| 1. First Stroke | Dawn | One stroke from the gate; stones | A dead end is where the walk in must end; corridors force the route; parity | 9 + 10 |
| 2. Ripples | Morning | Ripple stones | Rings: entered once, left beside the entrance | 10 + 10 |
| 3. Waves | Noon | Wave bands; _ichimatsu_ | Runs are atomic; plan turnarounds outside them | 11 + 9 |
| 4. The Master's Grooves | Afternoon | Given grooves (open & closed); then pebbles | Continuation; reading a unique answer; resolving "straight or turn" against the neighbors | 16 + 9 |
| 5. The Wide Rake | Evening | No U-turn in open sand; set rake down at an edge | Every reversal needs a pivot | 9 + 17 |
| 6. The Side Gate | Dusk | Two gates | Both ends fixed; double parity | 9 + 9 |
| 7. Silver Sand | Night | All of the above, larger gardens | Synthesis | 8 + 12 |
Table: World structure (148 gardens, all in the prototype: 72 on the main path, 76 optional extras). Worlds 1--3 are mostly *free* gardens (many valid rakings, scored against par); worlds 4--7 mostly *strict* (one valid raking).
</div>
[Free, then strict.]{.margin-note} The arc runs from optimization to deduction.
In worlds 1--3 any valid raking finishes a garden and the seal rewards calm; the player learns, by seeing their own rakings next to par, that calm rakings are long lines and rings.
From world 4 on, gardens have one valid raking, and the rules (grooves, then the wide rake) *enforce* a calm that the first half only rewarded.
It is not quite the same calm.
The generator keeps a free garden only if *some* par raking has no U-turn in open sand, but par is usually reached by others too: in 42 of the 54 free gardens of worlds 1--3 the par rakings include both kinds, and 327 of all 407 par rakings there turn back in open sand somewhere (_Waves_, for instance, has 34 par rakings, and 32 of them do).
So the wide rake is a stricter rule than the abbot's taste, to be learned as a new rule, not the taste made physical (Astra's correction: an earlier version of this page claimed the latter).
Curating the free worlds so that every par raking is graceful would leave too few gardens (11 of 54 qualify), so instead the wide rake's introduction makes the difference visible: its first garden is one whose par raking turns back in open sand (below).
The seal works the same way throughout; in a strict garden every valid raking is at par, so there it marks a garden raked without the abbot's help.
The final world is named for the "sea of silver sand" at [Ginkaku-ji](!W), raked "so that in moonlight its ridges appear as ripples in water" ([Cartwright 2019](https://www.worldhistory.org/Ginkakuji/ "'Ginkakuji', Cartwright 2019")).
[A short main path.]{.margin-note} Each world has a short main path, which is the template below and nothing more (8--16 gardens; 72 in all), followed by optional extras (76), listed after it as "more": the rest of the generated gardens, for practice and for seals.
In the first version of the campaign every garden was on the path, so grooves came after 38 gardens and the wide rake after 52 (Astra's count, from the unlocking rule); now they come after 21 and 32.
[Opening worlds.]{.margin-note} A world opens once two-thirds of the main path of the world before it has been raked, at any number of turns; extras raked instead count too.
Par is never required, and any garden can be finished, by persistence or with the abbot's help, so no player is ever locked out; within an open world every garden is open, so a player stuck on one rakes others.
[The first five gardens.]{.margin-note} Teaching is wordless, by construction:
(1) a one-cell-wide corridor (drawing, and the reveal);
(2) a 3×3 garden with a corner gate (more than one answer, and U-turns at the walls; the turn counter and par appear);
(3) the first real garden, the easiest of world 1's generated ones (5×5, one stone), where long lines turning at a stone first appear;
(4) a garden with a one-cell pocket behind a stone (the pocket can only be where the rake is set down);
(5) a garden of two-tone gravel in which the obvious place to end the walk is the wrong color, so every attempt ending there strands a cell (parity, made visible; the two tones fade to uniform white over the next two gardens).
The real garden comes before the two techniques, which only matter at its size: an early player, on a phone, did not "sense the potential until about six levels in", which is where the first real garden then came.
A 2×3 garden that taught the U-turn was dropped to make room; the 3×3 garden's par rakings turn back at the walls anyway.
[Inside a world.]{.margin-note} Each world after the first follows one template (Astra's): an introduction (a contrasting pair, below); two applications; a garden whose obvious move, learned from the gardens before it, is wrong; two combinations with earlier rules; a calm, easy garden; and a capstone.
Every garden is stored with its intended raking, the one observation it teaches, and the abbot's first hint, which states that observation; the test for keeping a garden is *what fact changes the player's plan?*
A garden is cut if the only way to solve it is trial and error, or if its clues trace the answer outright.
[Show the change.]{.margin-note} Each later rule is introduced by a contrasting pair (also Astra's suggestion): a small garden raked as usual, then the same garden with one change, both found by search.
A ripple stone, a band, or the wide rake is added to a garden whose every par raking breaks the new rule, so the plan that just worked visibly fails; one groove cuts a garden's 8 rakings to one; pebbles, one pale and one dark, replace the two grooves that pinned another garden down; a side gate leaves one of a garden's two wide-rake rakings, and not the par one.
The pairs have 17--23 cells of sand; the first campaign introduced bands in a 55-cell garden, and grooves and pebbles in 47-cell ones, which now come after two smaller applications.
The changed garden of each pair is demonstrated before the player rakes it (see [Interface](#interface)).
[The campaign.]{.margin-note} The prototype has all 148 gardens.
Thirty-four are teaching gardens: 13 of the prototype's original 14 (the 2×3 garden was dropped), the corridor, three _ichimatsu_ variants, four small introductions found by search (among them the two-tone parity garden), and the 13 gardens of the contrasting pairs.
The other 114 come from the pipeline below, with garden-like stones: for each size and mechanic, it generates up to twice as many candidates as needed (235 from 16,437 sampled layouts, in 33 minutes of computing) and keeps a subset spread over their difficulty range, no two alike up to rotation and reflection (apart from the pairs, which are alike on purpose).
Half of that time went on failing to find a single 9×9 pebble garden (none of 227 layouts passed the filters), so world 7's pebble gardens are 8×8.
[Ordered by the template.]{.margin-note} Each world is put in the template's order, with every role assigned by measurement.
Applications run from easy to middling (the hardest are extras): free gardens by size, then by how rare par is among their rakings; strict gardens by grade.
The trap is the garden whose obvious move is most clearly wrong: in a free garden, the one whose key observation (below) the near-misses, rakings one or two turns over par, most often get wrong (between 74% and 94% of them, in worlds 1--3); in a strict garden, one whose only raking turns aside at once, where walking straight in from the gate can be refuted only by lookahead.
The combinations add a rule from an earlier world (a second ripple stone; a ripple stone among bands; grooves or pebbles with a ripple or a band; the wide rake with either; pebbles at a side gate), generated for the purpose.
The calm garden is the one with the fewest turns per cell among the easier half of the rest, and the capstone is the hardest, or a hand-made showpiece (_Calm_, _Spiral_, _Side gate_, _Silver sand_).
A rule introduced mid-world (_ichimatsu_; the abbot's finished rings; his pebbles) gets its own short run of gardens after the trap.
[Hints that change the plan.]{.margin-note} Each generated garden's first hint is the one provable fact that most changes the player's plan, and the abbot points at the cells it names.
In a free garden, it is the fact true of every par raking (where it ends; how it begins; a row, column, or stretch of one raked in one straight line; whether the line turns or runs straight in some cell) that the most near-misses get wrong: a median of 50% of them.
In a strict garden, it is the fact about the only raking (where the rake is set down, or the line's shape in one cell) that most lowers the grade; when the rake is set down in a cell with one way in, the hint says so, giving the reason before the place.
Of the 80 strict gardens with such a hint, 62 need lookahead without it; with it, 16 need none, the 10 that needed nested lookahead need only one level, and the other 36 need fewer steps.
Of the 18 that need no lookahead anyway, it lets 4 be solved by local rules alone (the other 14 already are).
In a strict trap, the observation is the trap itself: walking straight in goes wrong.
None of this is curated or playtested: it is the raw material for step 3 of the [production plan](#production-plan), not its result.
[Daily garden: _ichi-go ichi-e_.]{.margin-note} One generated garden per day, the same for everyone, alternating free and strict.
The player may draw and undo freely, and here nothing is raked until they press **Rake** (the only garden with the button); that first raking is the day's record---"one time, one meeting" ([_ichi-go ichi-e_](!W "Ichi-go ichi-e")).
The result shared is the seal (or not) and turns versus par, with the garden's shape but not the stroke, which spoils nothing (everyone else is still raking it that day).
The prototype has it: a year of 366 gardens made by the campaign's generators and never used in it (even days free, odd days strict; 5×5 to 8×8; ripple stones, bands, grooves, pebbles, the wide rake and side gates all appear, with the rule stated in a line, since the daily garden does not wait for the player to reach the world that teaches it), picked by the local date, so everyone gets the same garden on the same day and the year repeats.
Drawing and undoing are free; **Rake** commits; the raking is then final for the day, with the record, the poem (the campaign's, by day of the year) and a streak of consecutive days shown, a share to pass on ("Samon · daily garden 2026-10-04 · 14 turns, par 12 · 静 · streak 3", the garden's shape drawn in box-drawing characters without the stroke, and a link that opens today's garden: see [Sharing](#sharing) below), and a new garden tomorrow.
[LinkedIn's _Zip_](https://www.gmpuzzles.com/blog/2025/03/introducing-zip "'Introducing Zip', Snyder 2025"), a daily Hamiltonian-path puzzle, suggests the appetite is there.
# Generating Gardens
[Pipeline.]{.margin-note} Handmade levels set the curriculum; generated ones fill it, and supply the daily garden.
The pipeline:
#. Sample a layout: garden size, stones, gate on the veranda wall, and the world's markings. Stones are placed the way a garden designer would: an odd number of pieces (1, 3 or 5), each of 1--3 cells (single, straight or L-shaped), none within a cell of another, with no mirror or half-turn symmetry, and covering at most ~15% of the garden. (The experiments elsewhere on this page used single-cell stones; the two samplers are compared below.)
#. Reject on parity (instant).
#. Count valid rakings with the exact solver (milliseconds for ≤8×8; the empty 7×7 garden's 1.5 million rakings take 2 seconds).
#. Prove par with the constraint solver, and also the best turn count achievable with no U-turn in open sand; reject free gardens where the two differ (their par raking is a meander, and the seal would reward an ugly answer).
#. Strict gardens: keep wide-rake layouts that are unique by themselves, or add grooves or pebbles from the graceful par raking until it is unique.
#. Grade with the deductive solver (below), and reject or reorder.
#. Curate the survivors for layout esthetics (a learned model could help here; see above), and write each one's observation and first hint.
[Yields.]{.margin-note} Strictness is cheap to generate.
Of 1,151 random 7×7--7×8 layouts, 69 (6.0%) passed all filters for groove gardens, needing a median 7 grooves.
Of 95,673 random 7×7--8×8 layouts with 1--3 stones, 2,444 (2.6%) were unique under the wide rake with no grooves at all; 58% of those had a turn density (turns per cell) of at most 0.40, which in practice separates calm answers from meandering ones.
At these rates, a year of daily gardens takes on the order of a CPU-hour; the prototype's year took 8 minutes of generation on two cores (`src/daily.py`), plus the campaign's unused candidates.
[Garden-like stones cost nothing.]{.margin-note} Realistic stone groups do not make good gardens rarer.
On 1,000 random 7×7 layouts from each sampler (garden-like layouts averaged 4.7 stone cells, single-cell ones 2.0), fewer garden-like layouts were solvable at all (28% against 39%), but the filters discard those anyway: usable free gardens came slightly more often (4.7% of layouts against 3.8%), gardens unique under the wide rake about as often (2.2% against 2.7%), and unique side-gate gardens slightly more often (1.8% against 1.3%).
Groove gardens needed the same median of 6 grooves; on small samples (25 each), the garden-like ones needed lookahead less often (28% against 84%), plausibly because larger stones make more corridors, which are local deductions.
[Grading.]{.margin-note} Difficulty is estimated by a deductive solver that works like a human, on the state of each boundary between cells (raked through, or not):
- *Tier 1*, local rules: a cell with two raked boundaries closes its others; a cell with exactly two open boundaries uses both; a cell with one is where the rake is set down; no closed loops; grooves, pebbles, bands, rings, and the wide rake's ban on open U-turns.
- *Tier 2*, global rules: checkerboard parity of the starting cell, connectivity of the unraked sand, and no premature joining of the stroke's two ends.
- *Tier 3*, lookahead: assume a boundary is raked through, propagate tiers 1--2, and reject it on contradiction.
- *Tier 4*, nested lookahead.
Generated groove gardens were 38% tier 1, 4% tier 2, and 58% tier 3 (median 3 lookahead steps); wide-rake gardens were 74% tier 3 (median 16 steps) and 26% tier 4.
Within a world, gardens follow the template ([Progression](#progression)), and its strict applications are ordered by tier, then lookahead count, then size.
These tiers are a proxy; only playtesting can say whether, for instance, the wide rake's lookahead is the kind players enjoy or the kind they resent.
# Interface
[Show, then do.]{.margin-note} A garden that brings in something new is first raked once by the monk himself: a ghostly fingertip leads him in from the gate along the intended raking, he rakes his way back out, the result rests for a moment ("That is a calm way to rake it: 3 turns, which is par. Now you."), and the garden is smoothed for the player.
This happens in the first garden (what raking *is*: in play, the first garden alone left it unclear what to do), in the first one scored by turns, and at the introduction of each later rule; it plays once by itself, **Watch** replays it, a tap skips it, and it costs nothing.
Watching the answer to an introduction spoils little, since an introduction is not meant to be hard; what it teaches is what the rule *looks like* when obeyed.
[Draw from the gate.]{.margin-note} Before the walk, the monk waits in the gateway, with a ring that pulses around him three times and then rests (it pulsed without end at first, which nags once the idea has been had); the player drags him in, and the path snaps cell to cell and leaves footprints.
Until a player's first walk, and always in the first garden, a ghostly fingertip then demonstrates the drag itself, three times; a press anywhere else says where to begin, and demonstrates again.
(The prototype first marked the gate's cell with a dashed circle, which says nothing about where to start, or that one drags.)
The walk follows the *direction* the hand is moving, not the cell the pointer happens to be in: arms are not mounted on tracks, and a hand sweeping along a row drifts off it, so a drag from one end of a row to the other that strays into the next row still draws the row (the first prototype snapped to whichever cell the pointer entered, and a long run would hop rows partway).
The axis of motion holds until the motion itself turns to within 30° of the other axis, or until the pointer is two cells off the run; a turn first finishes the run as far as the pointer went, so a rounded corner comes out as a corner; the direction is read over the last 0.4 cell of motion, so a wobble in place reads as nothing; and the pointer is captured, so the grip survives leaving the field or the window, a pointer beyond the walls being read as the nearest cell.
(This is the submenu problem of hierarchical menus, solved the same way: the intended movement is what matters, and a cursor that leaves the menu item on a slight diagonal toward the submenu has not left it.)
Dragging back over the last footprint retracts it; tapping any footprint walks the monk back to it; tapping the gate starts over.
Since either can throw away a long route by accident, the last rewind or restart can always be undone (**Restore**).
A rewind says what it leaves ("Stepped back 3 footprints: par is still possible from here"), replacing whatever verdict stood before, and the garden keeps the keyboard afterwards (after Astra's note that arrow keys scrolled the page after "Step back 3 for par" and that the old impossibility message lingered).
Moves into stones, walls, or footprints are ignored, and so is any move that breaks a rule on the spot (an open-sand U-turn under the wide rake, a turn at a pale pebble), with a soft scrape and a brief judder of the rake; a route that is doomed but not yet broken stays drawable, since noticing the trap is the puzzle.
A turn counter and the par sit at the top of the screen; nothing else does.
Whatever the game says to the player, as opposed to shows ("Drag the monk in from the gate", "Morning is open: 20 new gardens", a locked world's note, the help, the scroll's counts) is set in square brackets, as gwern.net sets the editor's words to the reader; rules, poems, counts and controls are not.
Every control, count and mark carries a tooltip with the word more that a hover might want (what Par is and who proved it, what Ask the abbot costs, what a world's extras are, what the seal says), as gwern.net puts one on everything that would bear it; the garden itself has none, since a tooltip over the sand would follow the hand through every walk.
[Letting go rakes.]{.margin-note} When the walk covers every cell and the finger lifts, the monk rakes; from the keyboard, the last step is followed by a beat in which it can still be taken back.
Raking is not final: tapping any cell of the raked garden smooths it back to the footprints, as far as that cell, and the walk goes on from there (and **Restore** rakes it again as it was), so a player who sees 19 turns against a par of 17 backs up with one tap, having seen the pattern.
An earlier design asked for an explicit **Rake** press after every garden (Astra's); in play it was a confirmation for an action that costs nothing, and it is kept only in the daily garden, where the first raking is the record.
[Sharing.]{#sharing .margin-note} A raking is worth passing on, and the page has no server, so the raking itself travels in the link: **Share this raking** gives the share sheet (or the clipboard) the raking drawn in box-drawing characters---light lines for the stroke, heavy for the walls with a tee where the gate breaks them, blocks for the stones---under a line with the garden, the turns, the par and the seal, and a link whose hash holds the raking as a code.
The code is small because the rules are strict about what a walk can do next: at each step there are at most three legal moves (and often one), so the walk is written as a number in the *mixed radix* of its legal moves, step by step in the order north, east, south, west, and a whole raking of a 7×7 garden takes some 20--50 bits (a mean of 22 bits over the campaign's intended rakings, against 86 at two bits a step; with the garden's identity and whether the abbot helped, 8--17 characters of base64url).
The garden is named in the code by 32 bits of a hash of its layout and rules, never by its place in the campaign, so a link survives a reordering, and a garden changed since is recognized as another rather than mistaken for the one meant (Astra's point: an 8-bit check, the first version, would have collided once in 256, and a seal must never land on a different puzzle).
No code decodes to a walk that breaks a rule, since the decoder re-derives the legal moves as it goes, and a code whose garden is gone or changed, or whose walk does not fit, is refused with a word.
Opening such a link shows the garden raked, with its seal, and says whose it is: it is credited to nobody, the viewer's own progress is untouched, and any tap smooths it away and leaves the garden theirs to rake from the gate (a raking so received cannot be walked back into, or restored).
The same idea carries a player's whole progress between devices without an account: **Carry my progress** makes a link whose hash lists every raked garden, by the same layout hash, with its state (raked and by how many turns over par, sealed in grey, sealed), the daily records by date with today's raking, and the unfinished walk of the garden last open, with the abbot's mark on it: about 7 characters a garden and 4 a day, so some 140 characters for 20 gardens and 2,500 for everything.
Opened on another device, it is offered rather than taken ("This link carries a player's progress: 23 of 148 gardens raked, 19 sealed, 4 daily gardens... Take it over here?"), and taking it joins the two: whichever side has the better state of a garden keeps it, the first raking of a day stays its record, and an entry whose garden is not in this edition, or has changed, is left out and said so, never assigned to the garden now at its place.
A link ending in `#daily` opens today's garden.
(The alternative, a shared raking as its index in a canonical enumeration of the garden's rakings, would be smaller still for free gardens, but a million-raking garden cannot be enumerated in the browser; the mixed radix is the same ordering, counted from one end, without the enumeration.)
[Phone geometry.]{.margin-note} Portrait phones are ≈390 points wide, so a 9-cell garden gives ≈42-point cells, about the 44-point minimum touch target; that sets 9×9 as the largest garden that needs no zoom, and the design never exceeds it.
The same interface works with a mouse.
[The reveal.]{.margin-note} When the walk is complete, the monk turns, sets down the rake, and walks backwards to the gate, pulling it; then the camera tilts from top-down to the abbot's view from the veranda, low sun on the ridges, and the seal is stamped or not.
The rake must glide: it follows the stroke's centerline continuously, through every quarter-circle, at a walking pace that eases in where it is set down and out at the gate, carving lit grooves and erasing the footprints as it passes, with a few grains flicked aside by the tines.
(The prototype's first reveal drew finished cells one at a time as line art; it read as chunky and unsatisfying, and the reveal is the game's payoff, so it is worth the most polish.)
It lasts a few seconds, scaled to the garden's size, and a tap skips it.
[The abbot judges your route, not his.]{.margin-note} Asking the abbot never reveals *his* answer; it decides, by search, what the player's own route can still become, and says only what it can prove.
Gardens this small are decided quickly (the slowest of the prototype's 4,440 test routes took 0.7 seconds in a desktop browser).
There are three verdicts.
The route can still be raked at par: he sketches its next groove, taken from a par raking that continues the player's route.
It can still be finished, but not at par: he marks the last footprint from which par was possible and says so with the action first ("step back 3 footprints to the marked footprint for par"), and offers the step.
It cannot be finished: he says why when there is a short reason (sand cut off, two dead ends, the wrong count of dark and light cells), marks the cells involved, and offers to step back to the nearest footprint from which the garden can still be finished, proven to be the nearest (if, in a large garden, that proof runs out of budget, he offers a footprint from which it can be finished, without calling it the nearest).
In a fresh garden the first request gives instead the garden's observation ("the top-right corner has only one way in").
If a search ever runs out of budget, he says he cannot tell yet, rather than guess.
[Help costs the vermilion.]{.margin-note} The observation is free, and so is the reason a walk has *stopped* (no step on is allowed, or every cell is covered but the walk can't end there), since a player can see that much already; an answer the abbot cannot give in time is free too.
Anything more---a verdict on a live route, a sketched groove, where to step back to---marks the garden as helped, and a helped raking at par earns the abbot's seal in grey ink instead of vermilion.
Astra argues for making every diagnosis free and charging only for a revealed move; but a verdict on a live route ("this can still reach par") is an oracle, which a player can consult after every step to search the garden mechanically, and the seal would then certify nothing.
The mark lasts until the garden is next raked, so starting over does not wash it out, and it travels with the route it helped: walking back into a helped raking and restoring it rakes it grey again (an early version let Step back and Restore turn a grey seal vermilion; Astra found it).
Raking the garden again unaided can still earn the vermilion seal, and the garden list keeps the better of the two (seals are drawn square, rakings off par as round dots).
The redemption is deliberate: a player can memorize a helped answer and rake it again, but making one peek cost a garden's seal forever would punish curiosity more than it rewards honesty.
Assists (below) do not affect the seal.
This replaces an earlier design, in which a hint revealed one groove of *the* intended raking; in free gardens that hint contradicted valid routes the player was already on.
(Hints that judge the player's own route, with proven rewinds, are Astra's idea.)
[Assists.]{.margin-note} Off by default after world 1, on toggles: a faint preview of the grooves under the footprints; the checkerboard; and hatching on sand the route has cut off from the gate.
There are no timers, move limits, or failure states; an impossible position is simply one the player has not yet noticed.
[Below the garden.]{.margin-note} Under the garden sit the turn count against par and three buttons (Step back, Start over, Ask the abbot; Restore, Watch, Rake and Next garden appear when they apply), then the status line, the assists (left in view, since a new player cannot know what an assist is until shown), a garden's rule where it brings one in, the list of gardens, and the help, folded into one line.
The list shows the worlds that are open, each as its main path, with the extras folded behind "+10 more" (unfolded by themselves while one of them is the garden open); a world not yet open is one muted line with its count and what opens it, so a new player sees a short list that lengthens as the day goes on.
Sound, volume and haptics live under a chevron beside the speaker in the header rather than in the list; the counts of a garden's rakings live in the tooltip of its par.
(The first version showed every world's pips, greyed, the three sound controls in the list, a "Sand" counter, the rule for every garden, and 290 words of help; on a phone the page ran to 2,400 pixels, and now to 1,400, with everything that was cut a hover or a tap away.)
[Accessibility.]{.margin-note} Every rule-bearing mark differs in shape, not only in color: hatching for bands, dashed rings for ripple stones, pale against dark pebbles, a dashed outline for cells the abbot points at, hatching for cut-off sand.
The game is fully playable from a keyboard (arrows walk, Backspace steps back, Enter skips the reveal and goes on) and respects reduced-motion settings, as the prototype already does.
A text description of each cell (terrain, mark, footprint, monk) with a coordinate-grid view would support non-visual play; that needs testing with screen-reader users rather than assuming.
[Prototype.]{.margin-note} A one-page HTML prototype, included with the code, implements this interface without the camera tilt: worlds that open in turn (with a switch that opens them all, for testing), main paths and extras, the monk's demonstrations and the first-drag hint, the monk waiting at the gate, drawing, rewinds and Restore, the turn counter against par, raking on letting go and stepping back after it, the refusals, the abbot's three verdicts, the three assists, the gliding reveal, the vermilion and grey seals, the poem after each raking and the fade to the next garden, the scroll of poems earned, the daily garden, the practice garden, the sound (the synthesized pipe, drips, wind, birds, crickets, footsteps, scrape, bell and _tok_; the shakuhachi recording; haptics), and the sharing (rakings and progress as codes in the URL's hash) (all in [Presentation](#presentation) and [Progression](#progression)).
It has the whole campaign of 148 gardens (see [Progression](#progression)), each with its poem; every par and raking count shown in it comes from the solvers, and its hint search agreed with the C solver on every one of 4,440 test routes (none ran out of budget).
Where the game would teach wordlessly, the prototype states each new rule in a line of text.
# Presentation
[Sand as a heightfield.]{.margin-note} The original sketch suggested a falling-sand particle simulation.
Raking does not make sand flow; it displaces it, so the right representation is a *heightfield* computed per pixel in a fragment shader: each cell's groove type (straight, quarter-circle around the corner, or the rake's start) gives the distance to each tine's centerline, a smooth groove profile turns distance into height, and the height's gradient is lit by a low sun whose angle follows the world's time of day.
Footprints are depressions stamped by decal; the reveal is a mask advancing along the stroke.
This is cheap on any phone, resolution-independent, and gives the one thing that matters visually: ridges catching light.
The prototype does a simpler version on the CPU, with about one height sample per screen pixel (32 to 144 per cell, so the sand is never visibly upscaled): footprints are pressed into the field as the player walks in, the abbot's grooves are raked into it in advance, and the reveal writes the rake's cross-section along the stroke frame by frame, the same engine as the practice garden (below).
Gravel grain is procedural and never the same twice: two octaves of value noise in the height (so the ridges catch light unevenly), a per-pixel fleck and a slow mottle in the color, a few specks on top, and a fresh seed every time a garden is laid out.
The rake is set down flush with whatever is behind its first cell (wall, stone, or the lane raked before), so no patch of sand is left between the start of the stroke and its neighbor.
The grid is an abstraction, and the rendering admits it: a turn is drawn as an ideal quarter-circle stamp, which no rigid rake sweeps exactly; the picture is faithful to the stroke, not to rake physics.
[Practice garden.]{.margin-note} A garden with no rules is always open: drag anywhere to rake, curves and all; place or lift stones (the sand under a lifted stone stays unraked); smooth it flat and start again.
Here the heightfield is written directly: the rake head, held perpendicular to its motion, sets the sand's cross-section (a ridge between every pair of tines) along its path, and the same low sun lights it.
It costs almost nothing, and it is the honest home for the sketch's falling-sand toy: a sandbox for the hands, kept apart from the puzzles so it never weakens their rules (Astra's suggestion).
The prototype has one.
[Palette.]{.margin-note} White-grey granite gravel, _sumi-e_ ink for stones and walls, moss green around the larger stones, and one accent: the vermilion of the novice's _ensō_ and the abbot's seal (the two marks of a brush in the garden; the first prototype had a vermilion triangle in the gateway as well, redundant with the gap in the wall and with the monk waiting in it, so it is gone, and only a side gate's exit keeps an outline triangle).
No sepia or yellow cast; the gravel at Kyoto temples is white.
The stones are ink-wash paintings, not 3D models; [_Ōkami_](!W) shows how far _sumi-e_ can go in motion.
In the prototype each stone is drawn as one: a contour that is lumpy and a little rough, flatter at its base where it sits in the sand; a soft contact shadow cast down and to the right, where the sand's light is from, as the blurred shadow of one path (so the two parts of a bent stone never darken each other), with a tighter, darker one at the base; an ink wash lighter toward the light; and a brushed edge, one band of varying weight, heavy where the ink pools on the shadow side and thinning to nothing where the brush ran dry on the lit side (in dark mode the edge is the pale rim light, so it is heavy on the lit side instead).
This replaced the first prototype's smooth capsules with a highlight, after GPT-6 Astra's second review of it; a first attempt with per-segment strokes and stepped washes looked jagged and banded, which Gwern caught, so everything is now one smooth band or gradient.
The novice is an [_ensō_](!W): one brushed circle, coming down rounded and heavy, thinning as it goes round, lifting before it closes, with two dry strands trailing past the lift, in vermilion, rubricated like an initial (the first prototype drew a filled black disc); he is drawn at the head of the walk, and, walking backwards, behind the rake; while he waits in the gateway he is its marker.
Everything around the garden follows [gwern.net's design](https://gwern.net/design): black text on white, greys for whatever is secondary, serif text with sans-serif controls, square corners, hairline rules, no shadows, and dark mode as gwern.net has it (#161616 under #f1f1f1); the vermilion stays the one colour, on the novice's ensō and the seal, and the abbot's marks, the hint highlights and the faint route through the footprints, which the first prototype tinted blue and red, are ink.
[Sound.]{.margin-note} The garden has its own sounds and almost no music.
A bamboo pipe drips into a stone basin now and then (every 7--23 s), and every minute or two the pipe tips onto its stone with a hollow knock and spills a few drops; a _bonshō_ bell sounds once at the close of each world, its low partials beating and lasting; the seal lands with a dull wooden _tok_.
Under them a wind moves, a bed of low filtered noise that wanders in level and colour every few seconds and swells into a gust every half-minute or so; by the local clock, small birds sing by day (phrases of two to six gliding notes, every 12--45 s, from 6 to 19) and crickets trill after dark (a high sine pulsed at about 28 Hz, from 20 to 5), so a page left open follows the evening.
Each footprint is a step on gravel (a burst of grains over a soft thump), and the raking is heard: the rake's scrape is grains of gravel under the tines, a looping crackle through a bandpass whose loudness, brightness and density follow the stroke's speed frame by frame, with the rasp of tines dragged sideways in every bend and the monk's backward step at every cell, lifting as the rake goes out through the gate.
All of these are synthesized in the page with Web Audio (decaying sines, filtered noise and synthesized crackle; a short feedback delay for the basin's echo), so they cost nothing to carry and never repeat exactly.
After a world's bell, and now and then otherwise (never within a quarter-hour of the last), a shakuhachi recording follows, faded in over 3 s and out over 4: the recordings are real and public domain, carried in the page as data: URLs with their credits and sources shown on the scroll of poems.
The first is _Shika no Tōne_ (鹿の遠音, "The Distant Cry of Deer"), the Kinko-school _honkyoku_ for two shakuhachi answering each other, played by Araki Kodō III (1879--1935) on Victor 13029 between 1925 and 1935, [from Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg) (a Japanese phonogram whose neighbouring rights have expired; the de-noised derivative released as public domain by its maker), re-encoded as mono MP3 at 32 kbps (1.5 MB).
Nothing sounds until the player has touched the page, as browsers require, and the first time it does a word beside the speaker says so.
Silence is one tap away: a speaker button in the header (or M) mutes everything, **Sound off** in the list of gardens is the same switch, a slider beside it sets the volume, and all of it is remembered.
The page also defers to what the browser and the device say.
There is no CSS media query for a sound preference (Media Queries Level 5 has `prefers-reduced-motion`, `-transparency`, `-contrast`, `-data` and `forced-colors`, nothing for audio), so the signals that exist are used instead: where the browser has the [Audio Session API](https://www.w3.org/TR/audio-session/) (Safari 17), the page's session is _ambient_, so a phone's silent switch mutes it and it mixes with whatever the player was already listening to rather than stopping it; the system's media keys (pause, stop) mute it while a recording plays, through the Media Session API, which also names the piece on the lock screen; and when the browser keeps the page's audio from running after a gesture (a site set to block sound, or Firefox's autoplay policy answering _disallowed_), the speaker shows it in red instead of pretending to play.
Haptics tick once per cell (8 ms, where the device can; a switch beside the sound's, shown only on such devices).
[A poem for each garden.]{.margin-note} The reward for raking a garden, at par or not, helped or not, is a poem: a moment after the reveal, a classical waka or haiku is laid over the raked sand in English on a white card with a hairline border (the first prototype blurred the grooves through a frosted plate), with the poet's name set in by an indent (no dash, and no kanji: the first version put 原文 beside the name as the button for the original, and the poet's name in kanji over the source, which Gwern found clutter), and a tap on "original" adds the Japanese and its source.
About ten seconds later the garden and its poem fade together and the next garden fades in, so the campaign can be played as one slow, unbroken sequence; a tap on the poem goes on at once, a tap beside it (on the sand around the card, or the walls) puts the poem away and stays with the garden (a player's note: interacting with the poem should go forth, dismissing it should go back; the first prototype had any tap stay), **Next garden** or Enter goes on at once, and nothing moves on while the page is hidden or scrolled away.
The poem rewards finishing, not par, so it never punishes asking for help; the seal still records how the garden was raked.
Every poem earned is kept on a scroll (**Poems**, beside the practice garden), in the order of the gardens with their Japanese and sources, with a count of what remains in each world, so the 76 extras have something to give besides seals; a garden's name on the scroll leads back to it.
The poems are real, and all written before 1900 (all but one by poets who died before it): 149 (one for each garden, and one for the practice garden) by 25 poets, from Bashō (34), Teika (23), Buson (20) and Rengetsu (13) down to single poems by Tsurayuki, Izumi Shikibu and Shiki.
Each is paired by image or by structure.
The first garden's poem is Bashō sweeping a temple garden before he goes on ("Let me sweep the garden / before I go— / willow leaves fall in the temple.").
The garden before the first ripple stone gets Ryōkan's new pond, where the frog jumps in and makes no sound; the stone arrives with Bashō's old pond.
The pair of gardens that introduces wave bands gets the two drafts of his Kiyotaki poem: waves "without a speck of dust" under the summer moon, and then, as he rewrote it on his deathbed, waves with green pine needles scattering into them.
The last garden of the main path gets the one classical poem about temple sand hauled and tended by monks, Bashō's at Kehi shrine, where the Yugyō abbots carried the sand in themselves: "The moon is clear / on the sand / the pilgrim monk carried."
The English versions are working translations, not quotations of published ones (see [Credits](#methods)).
[No koans.]{.margin-note} There is no narrative text, and no invented wisdom: apart from the rules, the only words are the poems, which are real.
The novice's progress is shown by his rake (bamboo, then the wide rake) and his robes (in the prototype he is an _ensō_, see [Palette](#presentation)); the abbot appears only as a seated figure on the veranda and as his seal.
Fake Zen aphorisms (or fake haiku) would cheapen exactly the thing the game is trying to be.
# Prior Art
- [**_Zen Puzzle Garden_**](https://www.lexaloffle.com/zen.php) (Lexaloffle 2003): raking as a puzzle, with the ice-sliding rule ("rake all of the sand without turning corners or breaking raked lines"); 64 gardens across 4 seasons, an editor, and later pushable lanterns and leaves to collect in order. Its solvability is NP-complete ([Houston et al 2012](https://arxiv.org/abs/1106.2104 "'Zen Puzzle Garden is NP-complete', Houston et al 2012")).
- [**_Zen Rock Garden_**](https://apps.apple.com/us/app/zen-rock-garden/id1553652093 "'Zen Rock Garden', ZenRock 2021") (ZenRock 2021): "Just trace over all the sand panels with a single stroke!", 300+ stages, with famous temple gardens as late motifs. This is the bare one-stroke rule, so _Samon_ does not claim it; what is new here is the gate-anchored walk-in/rake-out reveal, proven-par scoring, the _samon_ constraints, and the wide rake.
- **One-stroke and fill-the-board path puzzles**: _hitofude-gaki_ (一筆書き) apps; [_Flow Free_](!W) (2012: "Pair all colors, and cover the entire board"); [_Zip_](https://www.gmpuzzles.com/blog/2025/03/introducing-zip) (2025; Hamiltonian path through numbered waypoints, with walls); Nikoli genres like Numbrix/[Hidato](!W), whose given path fragments resemble the master's grooves, and [Masyu](!W), whose pearls resemble the pebbles.
- **Line puzzles with deep progression**: [_The Witness_](!W "The Witness (2016 video game)") (constraints on a drawn line, taught wordlessly); [_LYNE_](https://www.appspy.com/review/8095/lyne "'LYNE Review', AppSpy 2014") (2014); [_Cosmic Express_](https://apps.apple.com/us/app/cosmic-express/id1152474226 "'Cosmic Express', Draknek 2017") (2017; routing a single non-crossing track).
- **Optimization metrics**: [_Opus Magnum_](!W) and other Zachtronics games' histograms.
- **Exits and pulling**: [_Stephen's Sausage Roll_](!W) (the puzzle includes getting out), and [Sokoban](!W), of which raking is a kind of inverse: one pulls a rake rather than pushing blocks.
The general problem is NP-complete, as Hamiltonian path is NP-complete even on grid graphs ([Itai et al 1982](https://doi.org/10.1137/0211056 "'Hamilton Paths in Grid Graphs', Itai et al 1982")), so no simple trick solves every garden; but garden-sized instances are trivial for a computer, which is what makes proven pars, proof-backed hints, and generated levels possible.
# Production Plan
[Rules before art.]{.margin-note} The order of work (Astra's plan, adapted):
(1) prove the rules in a plain prototype---drawing, undo, the rules, the hint search, a few gardens---before any art (done: the prototype attached to this page);
(2) build a vertical slice of 12 gardens on real phones: the first five gardens, a ripple garden, a band garden, a groove garden, a pebble garden, a wide-rake garden, a side-gate garden, and a daily garden; one polished reveal; the histogram screen;
(3) curate the campaign from generated candidates, several times more than needed, ordered by grade and revised by the mistakes players actually make (a first, uncurated pass is in the prototype);
(4) only then the sensory layer: the shader, sound, haptics, low-power rendering, and accessibility passes.
[Pass or fail.]{.margin-note} The slice passes if players can say why a route failed after asking the abbot, carry an observation from one garden to the next, and choose to rake a finished garden again for the seal.
If they brute-force, shrink the gardens; if they only trace forced moves, remove clues; if the reveal feels like a wait, shorten it; if they spend their time in the practice garden instead of the gardens, the puzzles are the problem, not the sand.
# Risks and Open Questions
- **The free worlds may feel trivial.** Finishing an open garden is easy, and par is optional. Mitigation: keep world 1 short (9 gardens on its main path), give the seal real status, and introduce grooves (world 4) by the second hour (after 21 gardens). One early outside player raked at least 26 gardens on a phone, in the longer first campaign, and meant to play on.
- **The wide rake may be too hard.** The grader says every wide-rake garden needs lookahead. Mitigation: start it at 5×5, use the hint, and order by grade; if playtesting is bad, move it to an optional post-game.
- **Genre saturation.** One-stroke grids are a crowded genre. The differentiators are esthetic: the reveal, the score that *is* the beauty, the diegetic constraint vocabulary.
- **Scoring details.** Decided: leaving through the gate counts as a turn, and U-turns braced against walls are not free (as they are in real raking). Both stay simple, and the turn count already rewards long lines; either would be a one-line change, but would change every par.
- **Parity frustration.** Players who never discover parity will hit unwinnable endgames repeatedly. The two-tone tutorial gravel and the checkerboard assist are the mitigation; whether they suffice is an empirical question.
- **Esthetic curation.** Turn density and "no open U-turns" are crude proxies for beauty. A learned layout scorer, validated against human ratings of rendered gardens, is the obvious next experiment.
- **Hints as a crutch.** Proof-backed hints are strong: pressed repeatedly in a par-reachable route, the abbot sketches the whole answer. Decided after a first play session: helped rakings earn a grey seal (see [Interface](#interface)); Astra's design kept hints free and unpenalized, and its review argued for free diagnosis; explanations of a stopped walk are now free, verdicts on a live route are not. Open: whether players will memorize helped answers to re-rake them for vermilion, and whether that matters.
- **Demonstrations may teach copying.** Showing the calm raking of each introduction makes the rule's look concrete, but the player then retraces it. If playtests show players learning the shape rather than the rule, demonstrate each rule on a separate, smaller garden, and leave the pair's changed garden to the player.
# Methods {.collapse}
<div class="abstract-collapse">
How the numbers on this page were computed, and how they were checked. Everything is reproducible from the attached code.
</div>
[Exact solver.]{.margin-note} A depth-first search in C draws strokes from the gate inward (the reverse of raking; turns and U-turns are symmetric under reversal).
Sand cells are bits in a 128-bit mask (gardens ≤128 cells).
Each node prunes on: connectivity of the remaining sand through the head (a bitwise flood fill); degree (every unvisited cell needs two usable neighbors, except at most one, which must be the rake's start); checkerboard parity of the remaining cells; required and forbidden directions per cell (encoding bands and grooves); pebbles (a pale pebble's cell needs both neighbors on one axis usable, a dark pebble's one on each axis); ring contiguity; and the U-turn rules.
It enumerates the 1,510,446 rakings of the empty 7×7 garden in 1.9 seconds (33 million nodes).
[Validation.]{.margin-note} Its counts for empty gardens match [OEIS A145157](https://oeis.org/A145157) (_n_ = 3--7) and [A145156](https://oeis.org/A145156) (5×4, 5×6).
Against an independent brute-force enumerator (no pruning; rules checked only on complete paths) on ≈340,000 random small gardens (≈67,000 of them solvable) exercising every rule, it agreed on every count and every turn histogram; and on medium gardens it agreed with a separately-written [CP-SAT](https://developers.google.com/optimization/cp/cp_solver) model on counts, histograms, and minimum turns.
Pebbles were checked the same way, on 6,000 further random small gardens against brute force and 108 medium ones against CP-SAT, with no disagreements; the grader's pebble rules never derived a contradiction in a solvable garden, and every garden it solved, it solved to the right answer (711 random gardens, 79 of them with a unique raking).
Astra's review found two rules that the solvers and the prototype shared, and so could not catch by agreeing with each other: the bend just inside a side gate was drawn but not counted (in 13 of the 18 side-gate gardens then in the campaign), and the abbot's finished sand counted as a pivot for the wide rake.
Both were fixed in all three solvers and in the prototype, and checked again on 3,192 random small gardens against brute force (411 solvable, 132 of those with a side gate and 149 with finished sand) and on 120 medium ones against CP-SAT, with no disagreements.
The recheck also found a bug in the CP-SAT model: a sand cell walled in by finished sand has no arcs, and a circuit constraint silently leaves such nodes out, so the model "solved" gardens that cannot be raked; every cell now needs exactly one incoming arc.
[Par.]{.margin-note} The CP-SAT model is a single circuit through all sand cells plus a dummy node (arcs from the dummy to any start cell, and from the gate cell back to the dummy), with straightness indicators per cell and direction, so turns = (cells − 1) − Σ straight; rings constrain the number of arcs entering the ring set to exactly 1; bands and grooves fix edges; a pebble fixes its cell's straightness indicator and excludes it as the start; U-turns are three-literal clauses.
It proves par for random 8×8 gardens in 0.2--1.8 seconds, and for the empty 9×9 garden in 4.4 seconds (2 CPU cores).
[Grader and generator.]{.margin-note} The grader is the tiered propagation solver described in [Generating Gardens](#generating-gardens), in Python.
Groove clues and pebbles are chosen greedily (each round, of ~24 random candidate cells from the intended raking, add the one leaving the fewest valid rakings), then pruned of redundant ones.
Yields count every sampled layout, including those rejected on parity.
[Hint search.]{.margin-note} The prototype's hints run a JavaScript depth-first search that uses the same rule code as the player's moves and the C solver's pruning (connectivity, dead ends, parity, pebbles), with a node budget.
"Can this route still reach par?" needs no search: each garden ships with every one of its par rakings, and a route can reach par exactly when it is a prefix of one of them.
Two things keep the search small.
It remembers every position it has proved hopeless, where a position is the footprints, the head, and the last two moves (all the rules ever look at), so a dead end reached again by another route costs nothing.
And because a completion of a route also completes every shorter part of it, whether a route can be finished switches from yes to no only once along it, so the nearest footprint to step back to is found by binary search, and is proven nearest by a single proof of impossibility at the footprint after it.
On 4,440 routes over all 148 gardens (random legal walks, and prefixes of real rakings), every verdict it gave matched the C solver's, and no search ran out of budget (an earlier campaign had one such route, in a 9×9 wide-rake garden, where the abbot said he could not tell yet); of the 2,011 rewinds offered from stuck routes, 1,989 were proven nearest and matched the C solver's nearest footprint, and the other 22 (in three of the largest gardens) were footprints from which the garden could be finished, offered without the claim.
[Credits.]{.margin-note} Several ideas on this page come from a parallel design for the same sketch by GPT-6 Astra, "The Last Footprint" (unpublished): hints that judge the player's own route, with proven rewinds; restorable rewinds and an explicit commit (now kept only for the daily garden); the practice garden; the "any turn" clue (the dark pebble; we added the pale one); the world template and per-garden records; the production plan and its pass/fail criteria; and the symmetry argument.
Its five example gardens all check out under our solver.
Astra later reviewed the prototype: it found four bugs (the grey seal washed out by Step back and Restore; the uncounted side-gate bends; finished sand as a pivot; an inconclusive hint that cost the seal), a dead end explained by the wrong rule (a side gate's cell blamed on the wide rake), and the overclaim that the abbot's taste *is* the wide rake's physics, and it proposed the short main path and the contrasting pairs.
Astra also chose the poems: a sourced waka or haiku, with a working translation, for each garden of the earlier 136-garden campaign.
We carried them over to the 148 gardens (garden names follow layouts, so most pairings survived the reordering), paired the 13 new gardens (with 3 poems new to the selection), replaced two poems (Shiki's) whose texts we could not confirm, checked every Japanese text and attribution against a page that prints it, and revised 22 of the translations.
Two further passes (ours, then Astra's review of it) replaced 25 pairings judged weak (a felicitation poem, filler, poems fastened to their gardens by a pun or by a shared noun) with better classical poems, among them Ryōkan's new pond before Bashō's old one, Bashō's master carpenter for the abbot's grooves, the Man'yōshū moon-boat for the star garden, Tamemasa's unseen carp under trembling lotus leaves, and Saigyō's snipe at the threshold; Astra also offered two original poems of its own, which we left out, so that every word the player reads is still a real poem.
The texts, sources, pairings, credits and the log of replacements are in `poems/samon-poems.json`.
# License
[CC0.]{.margin-note} Everything here that is ours---Gwern's contributions and all the LLM-written work: this document, the design, the solvers, experiments, tests and build scripts, the level generator and the 148 gardens and 366 daily gardens it produced, the prototype and its sounds (every one synthesized in the page), the figures, the English working translations of the poems, and the ideas merged from GPT-6 Astra's parallel design---is released into the public domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
No attribution is required; none of it may be used to claim that Gwern or a contributor endorses a derived work.
The poems are classical Japanese texts, all first published before 1900, and in the public domain everywhere; the pages each text was checked against are linked beside it in the game and in `poems/samon-poems.json`.
The one recording, _Shika no Tōne_ played by Araki Kodō III (Victor 13029, recorded between 1925 and 1935), is [File:Shikanotoone_new.ogg](https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg) on Wikimedia Commons, re-encoded as mono MP3.
Commons records it as public domain: a Japanese phonogram whose neighbouring rights have expired under Article 101 of the Japanese Copyright Act, with the performer dead for over seventy years, and the de-noised derivative (Torsodog, 2008) released into the public domain by its maker.
In the United States, sound recordings fixed before 1946 came under federal copyright by the Music Modernization Act for 100 years from publication, so a pressing from 1930 is not public domain there until 2031; Commons hosts the file as public domain in both Japan and the United States, and we rely on that.
The page's look follows gwern.net's own stylesheets (colours and font stacks), which are [CC0](https://gwern.net/about#license); no fonts are bundled, and no third-party code or images are included.
The design document's figures are our own renders; the puzzle counts quoted from the [OEIS](https://oeis.org/) (A145157, A145156) are facts, not copies.
# See Also
- [Research ideas](/idea)
Solvers, experiments, the level generator, and the document's checks (src/)
src/campaign.py — Generate the full campaign (7 worlds, 148 gardens) with the garden-like stone sampler.
"""campaign.py: generate the full campaign (7 worlds, 148 gardens) with the garden-like stone sampler.
Each world mixes hand-made teaching gardens (the prototype's original 14, plus a few searched for) with generated
ones. Generated gardens pass the pipeline's filters (parity; solvable; free gardens: par reachable with no U-turn in
open sand, a calm par raking, a manageable number of par rakings; strict gardens: exactly one raking, solved by the
deductive grader) and are ordered by difficulty (free: by size, then how rare par is; strict: by grader tier,
lookahead steps, size). Names and intros are assigned here; first hints marked "auto" are derived and verified by
export_proto.py. Writes out/campaign.json and out/campaign_stats.json.
"""
from __future__ import annotations
import json
import math
import random
import sys
import time
from copy import deepcopy
from multiprocessing import Pool
from zg import Garden, turns_of
from zcp import optimize
from gen import parity_ok
from garden_sampler import garden_layout, add_side_gate, ichimatsu
from clues import make_unique, with_clues, make_unique_marks, with_marks
from grader import grade
from levels import from_dict, to_dict
OUT = "/home/claude/zen/out/campaign.json"
STATS = "/home/claude/zen/out/campaign_stats.json"
L = json.load(open("/home/claude/zen/out/levels.json"))
NAMES_REG = "/home/claude/zen/out/garden_names.json"
def key_of(g):
return (tuple(g.rows), tuple(g.gate), tuple(g.end) if g.end else None)
SWAP = str.maketrans("hv=|", "vh|=") # band and block orientations swap when the axes do
def canon(g):
"""The layout (terrain, gate cell, side-gate cell) up to rotation and reflection: two gardens with the same canon
look the same to a player, even if their gates face different ways or their clues differ."""
H, W = g.H, g.W
pts = [(g.gate[0], g.gate[1])] + ([tuple(g.end[:2])] if g.end else [])
forms = []
for tr in (False, True):
for fr in (False, True):
for fc in (False, True):
def f(r, c):
r, c = (H - 1 - r if fr else r), (W - 1 - c if fc else c)
return (c, r) if tr else (r, c)
h2, w2 = (W, H) if tr else (H, W)
grid = [[None] * w2 for _ in range(h2)]
for r in range(H):
for c in range(W):
a, b = f(r, c)
grid[a][b] = g.rows[r][c].translate(SWAP) if tr else g.rows[r][c]
forms.append((tuple("".join(x) for x in grid), tuple(f(*q) for q in pts)))
return min(forms)
# ---------------------------------------------------------------- generators (each returns a candidate dict or None)
def cand_free(g, max_at_par=40, min_rakings=20):
if not parity_ok(g):
return None
r = g.solve(mode=0, cap=3 * 10**6, nodelimit=2 * 10**9)
if r["nsol"] < min_rakings or not r["complete"] or r["capped"]:
return None
free = optimize(g, time_limit=20)
grace = optimize(g, nohairpin=2, time_limit=20)
if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]:
return None
at_par = r["hist"].get(free[0], 0)
cells = len(g.sand_cells())
if not (1 <= at_par <= max_at_par) or free[0] / cells > 0.45:
return None
return {"kind": "free", "garden": g, "cells": cells, "par": free[0], "rakings": r["nsol"], "at_par": at_par,
"rarity": math.log(r["nsol"] / at_par), "target": list(reversed(grace[2]))}
def cand_clued(g, rng, marks=False, max_clues=None):
if not parity_ok(g) or g.solve(mode=0, cap=2)["nsol"] < 2:
return None
free = optimize(g, time_limit=30)
grace = optimize(g, nohairpin=2, time_limit=30)
if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]:
return None
c = {"cells": len(g.sand_cells()), "target": list(reversed(grace[2]))}
if free[0] / c["cells"] > 0.45:
return None
cl = (make_unique_marks if marks else make_unique)(g, c["target"], rng)
if cl is None or len(cl) > (max_clues or (13 if marks else 9)):
return None
gu = (with_marks if marks else with_clues)(g, cl)
gr = grade(gu)
if not gr["solved"]:
return None
return {"kind": "strict", "garden": gu, "cells": c["cells"], "tier": gr["tier"], "trials": gr["trials"] or 0,
"clues": len(cl), "target": c["target"]}
def cand_wide(g, max_density=0.45):
if not parity_ok(g) and g.end is None:
return None
g.nohairpin = 2
g.braced_start = True
r = g.solve(mode=0, cap=2, stream=True)
if r["nsol"] != 1:
return None
d = r["sols"][0]["draw"]
cells = len(g.sand_cells())
if turns_of(g, d) / cells > max_density:
return None
gr = grade(g)
if not gr["solved"]:
return None
return {"kind": "strict", "garden": g, "cells": cells, "tier": gr["tier"], "trials": gr["trials"] or 0, "target": d}
def cand_side_marks(g, rng):
"""Side gate without the wide rake: pebbles from a graceful par raking until unique."""
r = g.solve(mode=0, cap=10**6)
if r["nsol"] == 0 or not r["complete"]:
return None
free = optimize(g, time_limit=20)
grace = optimize(g, nohairpin=2, time_limit=20)
if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]:
return None
target = list(reversed(grace[2]))
cl = make_unique_marks(g, target, rng)
if cl is None or len(cl) > 13:
return None
gu = with_marks(g, cl)
gr = grade(gu)
if not gr["solved"]:
return None
return {"kind": "strict", "garden": gu, "cells": len(g.sand_cells()), "tier": gr["tier"], "trials": gr["trials"] or 0,
"clues": len(cl), "target": target}
def make(spec, rng):
kind, p = spec["gen"], spec
H, W = p["H"], p["W"]
feats = dict(ripple=p.get("ripple", False), band=p.get("band"), closed_ring=p.get("closed_ring", False))
if kind == "free":
g = garden_layout(H, W, rng, **feats)
if g is not None and p.get("ripple2"):
from garden_sampler import add_ripple
rows = [list(r) for r in g.rows]
if not add_ripple(rows, rng, H, W):
return None
g = Garden(["".join(r) for r in rows], g.gate)
return cand_free(g) if g is not None else None
if kind in ("grooves", "pebbles"):
g = garden_layout(H, W, rng, **feats)
return cand_clued(g, rng, marks=(kind == "pebbles")) if g is not None else None
if kind == "wide":
g = garden_layout(H, W, rng, **feats)
return cand_wide(g) if g is not None else None
if kind == "side_wide":
g = garden_layout(H, W, rng, **feats)
if g is None or not add_side_gate(g, rng):
return None
return cand_wide(g)
if kind == "side_pebbles":
g = garden_layout(H, W, rng, **feats)
if g is None or not add_side_gate(g, rng):
return None
return cand_side_marks(g, rng)
raise ValueError(kind)
CACHE = "/home/claude/zen/out/campaign_cache"
def run_spec(args):
spec, seed = args
import os, pickle
os.makedirs(CACHE, exist_ok=True)
path = f"{CACHE}/{spec['id']}.pkl"
if os.path.exists(path):
return pickle.load(open(path, "rb"))
res = _run_spec(spec, seed)
pickle.dump(res, open(path, "wb"))
return res
def _run_spec(spec, seed):
rng = random.Random(seed)
t0, tried, found, seen = time.time(), 0, [], set()
want = math.ceil(spec["n"] * spec.get("over", 2)) # generate extra, keep a subset spread over difficulty
while len(found) < want and time.time() - t0 < spec.get("budget", 300):
tried += 1
c = make(spec, rng)
if c is None or key_of(c["garden"]) in seen:
continue
seen.add(key_of(c["garden"]))
c["spec"] = spec["id"]
found.append(c)
return spec["id"], found, tried, time.time() - t0
# ---------------------------------------------------------------- the campaign
SPECS = [
# world 1: free gardens with stones
dict(id="w1-55", world=1, gen="free", H=5, W=5, n=2), dict(id="w1-56", world=1, gen="free", H=5, W=6, n=2),
dict(id="w1-66", world=1, gen="free", H=6, W=6, n=3), dict(id="w1-67", world=1, gen="free", H=6, W=7, n=2),
dict(id="w1-77", world=1, gen="free", H=7, W=7, n=2), dict(id="w1-78", world=1, gen="free", H=7, W=8, n=2),
# world 2: ripple stones
dict(id="w2-66", world=2, gen="free", H=6, W=6, ripple=True, n=3), dict(id="w2-67", world=2, gen="free", H=6, W=7, ripple=True, n=4),
dict(id="w2-77", world=2, gen="free", H=7, W=7, ripple=True, n=4), dict(id="w2-78", world=2, gen="free", H=7, W=8, ripple=True, n=2),
dict(id="w2-88", world=2, gen="free", H=8, W=8, ripple=True, ripple2=True, n=1, budget=600, role="combo", names="two_ripple"),
dict(id="w2-77x2", world=2, gen="free", H=7, W=7, ripple=True, ripple2=True, n=2, budget=600, role="combo", names="two_ripple"),
# world 3: wave bands (ichimatsu gardens are added by hand below)
dict(id="w3-67", world=3, gen="free", H=6, W=7, band=True, n=4), dict(id="w3-77", world=3, gen="free", H=7, W=7, band=True, n=4),
dict(id="w3-78", world=3, gen="free", H=7, W=8, band=True, n=3),
dict(id="w3-88", world=3, gen="free", H=8, W=8, band=True, ripple=True, n=1, budget=600, role="combo", names="band_ripple"),
dict(id="w3-77br", world=3, gen="free", H=7, W=7, band=True, ripple=True, n=1, budget=600, role="combo", names="band_ripple"),
# world 4: grooves, closed rings, pebbles
dict(id="w4-g66", world=4, gen="grooves", H=6, W=6, n=3), dict(id="w4-g77", world=4, gen="grooves", H=7, W=7, n=3),
dict(id="w4-g78", world=4, gen="grooves", H=7, W=8, n=3), dict(id="w4-ring", world=4, gen="grooves", H=7, W=8, closed_ring=True, n=2, budget=600, over=1.5),
dict(id="w4-p66", world=4, gen="pebbles", H=6, W=6, n=2), dict(id="w4-p77", world=4, gen="pebbles", H=7, W=7, n=1),
dict(id="w4-p78", world=4, gen="pebbles", H=7, W=8, n=2),
dict(id="w4-gr", world=4, gen="grooves", H=7, W=7, ripple=True, n=1, over=4, budget=600, role="combo", names="groove_ripple"),
dict(id="w4-pb", world=4, gen="pebbles", H=7, W=7, band=True, n=1, budget=600, role="combo", names="pebble_band"),
# world 5: the wide rake
dict(id="w5-55", world=5, gen="wide", H=5, W=5, n=3), dict(id="w5-56", world=5, gen="wide", H=5, W=6, n=2),
dict(id="w5-66", world=5, gen="wide", H=6, W=6, n=3), dict(id="w5-67", world=5, gen="wide", H=6, W=7, n=3),
dict(id="w5-77", world=5, gen="wide", H=7, W=7, n=4), dict(id="w5-78", world=5, gen="wide", H=7, W=8, n=2),
dict(id="w5-88", world=5, gen="wide", H=8, W=8, n=2),
dict(id="w5-wr", world=5, gen="wide", H=7, W=7, ripple=True, n=1, budget=600, role="combo", names="wide_ripple"),
dict(id="w5-wb", world=5, gen="wide", H=7, W=7, band=True, n=1, budget=600, role="combo", names="wide_band"),
# world 6: the side gate
dict(id="w6-67", world=6, gen="side_wide", H=6, W=7, n=3),
dict(id="w6-77", world=6, gen="side_wide", H=7, W=7, n=4), dict(id="w6-78", world=6, gen="side_wide", H=7, W=8, n=3),
dict(id="w6-88", world=6, gen="side_wide", H=8, W=8, n=1), dict(id="w6-p77", world=6, gen="side_pebbles", H=7, W=7, n=3, budget=600, role="combo"),
# world 7: everything, larger
dict(id="w7-wr", over=1.5, world=7, gen="wide", H=8, W=8, ripple=True, n=4, budget=900), dict(id="w7-wb", over=1.5, world=7, gen="wide", H=8, W=8, band=True, n=3, budget=900),
dict(id="w7-prb", over=1.5, world=7, gen="pebbles", H=8, W=8, ripple=True, band=True, n=3, budget=900, role="combo"),
dict(id="w7-swr", over=1.5, world=7, gen="side_wide", H=8, W=8, ripple=True, n=2, budget=900, role="combo"),
dict(id="w7-ring", over=1.5, world=7, gen="wide", H=8, W=8, closed_ring=True, n=2, budget=900),
dict(id="w7-w99", over=1.5, world=7, gen="wide", H=9, W=9, n=3, budget=900),
dict(id="w7-p99", over=1.5, world=7, gen="pebbles", H=9, W=9, n=2, budget=900,
fallback=dict(id="w7-p88", over=1.5, gen="pebbles", H=8, W=8, budget=900)),
]
NAMES = { # names for gardens with no special feature, in order of play
1: ["Morning dew", "Mist", "First bell", "Sparrow", "Cold sand", "Lantern", "Bamboo", "The long wall", "Wind chime",
"Pine shadow", "Heron's step", "Quiet", "Sunrise", "Kettle", "Swept path"],
2: ["Koi", "Bell's echo", "Turtle", "Stepping stone", "Pond edge", "Water lily", "Drip", "Carp", "Lotus", "Still water",
"Well", "Spring", "Frog", "Mossy bank", "Moon in a bowl", "Rain on the roof"],
3: ["High tide", "Breakers", "Current", "Shallows", "Sandbar", "Reef", "Low tide", "Crosscurrent", "Undertow", "Swell",
"Estuary", "Spring tide", "Noon bell"],
4: ["Afternoon", "Tea house"],
5: ["Pivot", "Hairpin", "Brace", "Long rake", "Turning stone", "Horseshoe", "Coil", "Shell", "Crescent", "Evening bell",
"Swallow", "Bat", "Owl", "Ember", "Firefly", "Long shadow", "Last light", "Cicada", "Dragonfly", "Lanternlight", "Smoke"],
6: ["Two doors", "Visitor", "Garden path", "Through the wall", "Gatekeeper", "Postern", "Threshold", "Late arrival",
"Crossing", "Wicket", "Hinge", "Latch", "Bolt", "Dusk", "Errand"],
7: ["Moonrise", "Starfield", "Lantern festival", "Night heron", "Owl's hour", "Milky Way", "Frost", "Moonlit sea", "Midnight",
"Deep night", "Night rain", "Dark water", "Stargazer", "The abbot's garden", "Moonbeam", "Moon pool", "Last bell",
"Small hours", "Before dawn", "Sleeping temple"],
}
POOLS = { # names that describe a feature, used only for gardens that have it
"two_ripple": ["Twin rings", "Two moons", "Paired wells"],
"band_ripple": ["Noon sun", "Island in the current"],
"ichimatsu": ["Tatami", "Weave", "Lattice", "Checkered shade"],
"groove": ["His first lines", "Unfinished", "Interrupted", "Called away", "Old grooves", "Continuation", "Halfway",
"Left for you", "Plain speaking", "Margin notes"],
"ring": ["The abbot's ring", "Closed circle"],
"pebble": ["Pale and dark", "White stones", "Black stones", "Go board", "Counting stones", "Afternoon tea"],
"groove_ripple": ["Ripple and groove"],
"pebble_band": ["Pebbles in the current"],
"wide_ripple": ["Whorl"],
"wide_band": ["Wake"],
}
def search_two_tones(rng, budget=120):
"""World 1's parity lesson: a small garden where the obvious place to end (the cell farthest from the gate)
is the wrong colour, so every walk that ends there strands a cell."""
t0 = time.time()
best = None
while time.time() - t0 < budget:
H, W = rng.choice([(4, 5), (5, 5), (5, 4)])
g = garden_layout(H, W, rng, n_pieces=1, max_frac=0.1)
if g is None or not parity_ok(g):
continue
r = g.solve(mode=0, cap=10**6, stream=True)
if not (2 <= r["nsol"] <= 40):
continue
# BFS distance from the gate
from collections import deque
start = (g.gate[0], g.gate[1]); dist = {start: 0}; q = deque([start])
while q:
a = q.popleft()
for d in ((1, 0), (-1, 0), (0, 1), (0, -1)):
b = (a[0] + d[0], a[1] + d[1])
if g.is_sand(*b) and b not in dist:
dist[b] = dist[a] + 1; q.append(b)
far = max(dist, key=lambda c: (dist[c], c in ((0, 0), (0, W - 1), (H - 1, 0), (H - 1, W - 1))))
ends = {tuple(s["draw"][-1]) for s in r["sols"]}
if far in ends or far[0] not in (0, H - 1) or far[1] not in (0, W - 1):
continue
c = cand_free(g, max_at_par=10**9, min_rakings=2)
if c is None:
continue
score = (len(ends) <= 3, -r["nsol"])
if best is None or score > best[0]:
best = (score, c, far, sorted(ends))
return best
def search_small(gen, H, W, rng, budget=120, side=False, ripple=False, max_tier=3):
t0 = time.time()
best = None
while time.time() - t0 < budget:
if ripple:
g = garden_layout(H, W, rng, ripple=True, n_pieces=1)
else:
g = garden_layout(H, W, rng, n_pieces=1, max_frac=0.1)
if g is None:
continue
if side and not add_side_gate(g, rng):
continue
c = cand_free(g) if gen == "free" else cand_wide(g)
if c is None or (gen != "free" and c["tier"] > max_tier):
continue
score = (-c.get("tier", 0), c.get("rarity", 0))
if best is None or score > best[0]:
best = (score, c)
return best[1] if best else None
def hand(name, world, g, intro=None, hint="auto", tone=0.0, kind=None, cells=None):
return {"name": name, "world": world, "garden": to_dict(g), "intro": intro, "hint": hint, "tone": tone, "kind": kind,
"hint_cells": cells}
def main():
t_start = time.time()
rng = random.Random(2026)
stats = {}
with Pool(2) as pool:
jobs = [(s, 1000 + i) for i, s in enumerate(SPECS)]
results = []
for res in pool.imap_unordered(run_spec, jobs, chunksize=1):
results.append(res)
print(f" done {res[0]} ({len(results)}/{len(jobs)})", flush=True)
by_spec = {}
for sid, found, tried, secs in results:
by_spec[sid] = found
stats[sid] = {"tried": tried, "found": len(found), "seconds": round(secs)}
print(f"{sid:8s} tried {tried:6d} found {len(found):3d} in {secs:5.0f}s", flush=True)
# a spec that found too few falls back to an easier one in the same slot (recorded in the stats)
for i, s in enumerate(SPECS):
if len(by_spec[s["id"]]) < s["n"] and "fallback" in s:
fb = dict(s["fallback"], world=s["world"], n=s["n"])
sid, found, tried, secs = run_spec((fb, 5000 + i))
print(f"{s['id']} found {len(by_spec[s['id']])} < {s['n']}: fallback {sid} found {len(found)} in {secs:.0f}s", flush=True)
stats[sid] = {"tried": tried, "found": len(found), "seconds": round(secs), "fallback_for": s["id"]}
by_spec[s["id"]] = found + by_spec[s["id"]]
# hand-made and searched teaching gardens (the searches are cached too)
import os, pickle
tpath = f"{CACHE}/teaching.pkl"
if os.path.exists(tpath):
two, wide_intro, side_intro, ripple_intro = pickle.load(open(tpath, "rb"))
else:
two = search_two_tones(rng)
wide_intro = search_small("wide", 5, 5, rng)
side_intro = search_small("wide", 6, 6, rng, side=True)
ripple_intro = search_small("free", 5, 6, rng, ripple=True)
pickle.dump((two, wide_intro, side_intro, ripple_intro), open(tpath, "wb"))
if not (two and wide_intro and side_intro and ripple_intro):
sys.exit(f"teaching search failed: {bool(two)}, {bool(wide_intro)}, {bool(side_intro)}, {bool(ripple_intro)}")
stats["two_tones"] = {"far_cell": two[2], "ends": two[3]}
# contrasting pairs that introduce each rule: the same small garden, before and after one change (pairs.py)
ppath = f"{CACHE}/pairs.pkl"
if os.path.exists(ppath):
PAIRS = pickle.load(open(ppath, "rb"))
else:
import pairs as pairmod
with Pool(2) as pool:
PAIRS = dict(pool.imap_unordered(pairmod.run, [(nm, 7000 + i, None) for i, nm in enumerate(pairmod.MAKERS)]))
pickle.dump(PAIRS, open(ppath, "wb"))
if any(v is None for v in PAIRS.values()):
sys.exit(f"pair search failed: {[k for k, v in PAIRS.items() if v is None]}")
stats["pairs"] = {k: dict(v["facts"], tried=v["tried"], size=[v["B"].H, v["B"].W]) for k, v in PAIRS.items()}
# a searched teaching garden can coincide with a generated candidate of the same size: keep it for teaching only
reserved = {key_of(c["garden"]) for c in (two[1], wide_intro, side_intro, ripple_intro)}
for sid in by_spec:
dropped = [c for c in by_spec[sid] if key_of(c["garden"]) in reserved]
if dropped:
print(f"{sid}: {len(dropped)} candidate(s) kept back as teaching gardens", flush=True)
by_spec[sid] = [c for c in by_spec[sid] if key_of(c["garden"]) not in reserved]
w = json.load(open("/home/claude/zen/out/wide_pick.json"))
t = json.load(open("/home/claude/zen/out/two_pick.json"))
wide_list = json.load(open("/home/claude/zen/out/search3_wide.json"))
gw = next(from_dict(d) for dens, trials, k, d, draw in wide_list
if d["rows"] == ['........', '........', '..#.....', '....#...', '........', '........', '......#.'] and d["gate"] == [6, 5, "S"])
mp = json.load(open("/home/claude/zen/out/marks_pick.json"))
# layouts already in the campaign, up to symmetry: a generated garden may be a mirror image of a teaching garden,
# and the same stones and gate can pass the filters of two worlds (free, and wide)
teaching = [Garden(["....", "###.", "...."], (2, 0, "S")), Garden(["...", "..."], (1, 0, "S")), Garden(["...", "...", "..."], (2, 0, "S")),
Garden(["#....", "....#", ".....", "....."], (3, 0, "S")), two[1]["garden"], wide_intro["garden"], side_intro["garden"],
ripple_intro["garden"], gw, from_dict(w["garden"]), from_dict(t["garden"]), from_dict(mp["garden"])] + \
[from_dict(L[k]["garden"]) for k in L] + [v[x] for v in PAIRS.values() for x in ("A", "B") if x in v]
used = {canon(x) for x in teaching}
def pick(sid, n, order):
cs, ks = [], set()
for c in sorted(by_spec[sid], key=order): # also drop mirror images among the spec's own candidates
k = canon(c["garden"])
if k not in used and k not in ks:
cs.append(c); ks.add(k)
if len(cs) < len(by_spec[sid]):
print(f"{sid}: {len(by_spec[sid]) - len(cs)} candidate(s) skipped, their layout (up to symmetry) already used", flush=True)
if len(cs) < n:
sys.exit(f"spec {sid}: only {len(cs)} of {n}")
# spread over the difficulty range rather than taking the easiest n
idx = [round(i * (len(cs) - 1) / max(1, n - 1)) for i in range(n)] if n > 1 else [len(cs) // 2]
out = [cs[i] for i in sorted(set(idx))][:n] if len(set(idx)) == n else cs[:n]
used.update(canon(c["garden"]) for c in out)
return out
free_order = lambda c: (c["cells"], c["rarity"])
strict_order = lambda c: (c["tier"], c["trials"], c["cells"])
def gen_entries(world, sids, order):
out = []
for s in SPECS:
if s["world"] == world and s["id"] in sids:
out += pick(s["id"], s["n"], order)
return sorted(out, key=order)
# ---------------------------------------------------------------- order each world by the template
# intro; applications (easy to hard); a trap (the garden that most punishes the obvious first move, walking straight
# in from the gate); combinations with earlier rules; a calm, easy garden; a capstone. Worlds that introduce a second
# mechanic (ichimatsu; closed rings and pebbles) give it its own short section, after the trap.
from hints import trap_score, near_misses
def profile(c):
"""Ordering data: difficulty, turn density (calmness) and trap score, from the solvers."""
g = c["garden"] if not isinstance(c["garden"], dict) else from_dict(c["garden"])
if c["kind"] == "free":
P = [s_["draw"] for s_ in g.solve(mode=2, bound=c["par"], cap=10**6, stream=True)["sols"]]
c["trap"] = trap_score(g, c["par"], P, near_misses(g, c["par"]))
c["density"] = c["par"] / c["cells"]
c["diff"] = (c["cells"], c["rarity"])
else:
c["trap"] = trap_score(g, None, draw=c["target"])
c["density"] = turns_of(g, c["target"]) / c["cells"]
c["diff"] = (c["tier"], c["trials"], c["cells"])
return c
def hand_profile(e, kind):
g = from_dict(e["garden"])
if kind == "free":
r = g.solve(mode=0, cap=10**8)
free = optimize(g, time_limit=60)
e.update(kind="free", par=free[0], cells=len(g.sand_cells()), rarity=math.log(r["nsol"] / r["hist"][free[0]]))
else:
gr = grade(g)
d = g.solve(mode=0, cap=2, stream=True)["sols"][0]["draw"]
e.update(kind="strict", tier=gr["tier"], trials=gr["trials"] or 0, cells=len(g.sand_cells()), target=d)
return profile(e)
def spread(n, k):
"""k indices spread over the easier part of range(n): the easiest, then evenly spaced short of the hardest (the
capstone is the world's hard garden; the hardest applications are extras)."""
if k >= n:
return list(range(n))
return [round(i * (n - 1) / k) for i in range(k)]
def arrange(apps, combos=(), capstone=None, sections=(), n_apps=2, n_combos=2, showpiece=None):
"""The template after a world's introduction: two applications (easy to hard, then a hand-made showpiece if the
world has one), the trap, any mid-world sections, two combinations, the calm garden and the capstone form the
main path; the other applications and combinations are optional extras. Returns (main, extras), with roles
recorded."""
apps, combos = list(apps), list(combos)
if capstone is None: # the hardest garden closes the world
capstone = max(apps + combos, key=lambda c: c["diff"])
(apps if capstone in apps else combos).remove(capstone)
apps.sort(key=lambda c: c["diff"])
trap = None
cands = [c for c in apps if c["trap"] > 0]
if cands: # the strongest trap; ties: nearest the middle difficulty
trap = max(cands, key=lambda c: (round(c["trap"], 2), -abs(apps.index(c) - len(apps) // 2)))
apps.remove(trap)
easy = apps[: max(1, len(apps) // 2)] # a calm garden: easy, and with the fewest turns per cell
calm = min(easy, key=lambda c: c["density"])
apps.remove(calm)
main_apps = [apps[i] for i in spread(len(apps), n_apps)]
combos.sort(key=lambda c: c["diff"])
main = main_apps + ([showpiece] if showpiece else []) + ([trap] if trap else [])
extras = [c for c in apps if c not in main_apps]
for sec_main, sec_extra in sections: # (main part, extra part) of a rule introduced mid-world
main += sec_main; extras += sec_extra
main += combos[:n_combos] + [calm, capstone]
extras += combos[n_combos:]
for c in main + extras:
c.setdefault("role", "application")
if trap: trap["role"] = "trap"
calm["role"], capstone["role"] = "calm", "capstone"
for c in combos: c["role"] = "combination"
for c in extras: c["extra"] = True
return main, extras
def gen(world, combo=False, only=None):
out = []
for s_ in SPECS:
if s_["world"] == world and (s_.get("role") == "combo") == combo and (only is None or s_["id"] in only):
for c in pick(s_["id"], s_["n"], lambda c: (c["cells"], c["rarity"]) if c["kind"] == "free" else (c["tier"], c["trials"], c["cells"])):
out.append(profile(dict(c, world=world, name=None, pool=s_.get("names"))))
return out
def pair_entry(kind, which, world, name, intro, role):
return dict(hand(name, world, PAIRS[kind][which], intro), role=role, pair=kind)
SAME = "The next garden is this one again, with one change."
W, X = {}, {} # each world's main path, and its optional extras
# ---- world 1: the teaching gardens, then the template's tail (no combinations: there is nothing earlier). The first
# real garden comes third, before the two technique lessons (dead ends, parity), which only matter at its size: an
# early player did not "sense the potential until about six levels in", when it came sixth. (A 2x3 garden that taught
# the U-turn was dropped: the 3x3 garden's par rakings make U-turns anyway.)
fs = hand_profile(hand("First stroke", 1, from_dict(L["first"]["garden"]), None,
"Both par rakings begin by walking straight up from the gate, and end near the top-left corner."), "free")
calm1 = hand("Calm", 1, from_dict(L["calm"]["garden"]), None, "At par, the walk ends right beside the gate.")
m, X[1] = arrange(gen(1) + [fs], capstone=calm1)
teach = [hand("The corridor", 1, Garden(["....", "###.", "...."], (2, 0, "S")),
"Drag the monk in from the gate, covering every cell of sand in one stroke; when you let go, he rakes his way back out.",
"There is only one way through: follow the corridor."),
hand("Nine squares", 1, Garden(["...", "...", "..."], (2, 0, "S")),
"Fewer turns is calmer. Match par for the abbot's seal.",
"Fewer turns mean longer lines: run each one from wall to wall."),
hand("The pocket", 1, Garden(["#....", "....#", ".....", "....."], (3, 0, "S")),
"A cell with only one way in is where the walk must end: the rake is set down there.",
"The top-right corner has only one way in, so the walk has to end there.", cells=[[0, 4]]),
hand("Two tones", 1, two[1]["garden"],
"Dark and light: every step changes colour, so a stroke ends on the colour there is more of.", "auto-parity", tone=1.0)]
for e in teach:
e["role"] = "intro"
W[1] = teach[:2] + m[:1] + teach[2:] + m[1:]
W[1][5]["tone"], W[1][6]["tone"] = 0.6, 0.3 # the two tones fade over the next gardens
# ---- world 2: ripple stones, introduced by a pair; combinations have two ripple stones
W[2] = [pair_entry("ripple", "A", 2, "Plain stone", f"Rake this one as usual. {SAME}", "pair"),
pair_entry("ripple", "B", 2, "First ripple", "The same garden, with a ripple stone: its eight neighbours must be raked as one unbroken ring.", "intro")]
ripples = hand("Ripples", 2, from_dict(L["ripple"]["garden"]), None,
"Rake the ring in one run; at par, the rest of the garden wraps around it in long lines.")
m, X[2] = arrange(gen(2), gen(2, combo=True), showpiece=ripples)
W[2] += m
X[2] = [dict(ripple_intro, name=None, world=2)] + X[2]
# ---- world 3: wave bands, introduced by a pair; then ichimatsu, its own short section; combinations add a ripple stone
ich = []
# ichimatsu variants: only 3x3 blocks can be raked at all (2x2 blocks have no valid raking at any gate), and a 6x6
# garden with its blocks flipped is a mirror image of the original, as a 9x6 one is of a 6x9; so the variants move
# the gate off the corner and widen the garden (each has one raking; all are distinct up to symmetry)
for nm, (nbr, nbc, b, gc, flip) in (("ichimatsu-gate2", (2, 2, 3, 2, False)), ("ichimatsu-6x9", (2, 3, 3, 0, False)),
("ichimatsu-6x9-gate2", (2, 3, 3, 2, False))):
gi = ichimatsu(nbr, nbc, b, gc, flip)
r = gi.solve(mode=0, cap=50)
if not (1 <= r["nsol"] <= 6 and parity_ok(gi)):
sys.exit(f"ichimatsu variant {nm}: {r['nsol']} rakings")
ich.append((r["nsol"], nm, gi))
ich.sort(key=lambda x: (len(x[2].sand_cells()), x[0]))
stats["ichimatsu_variants"] = [(n, nm, x.rows, list(x.gate)) for n, nm, x in ich]
ich_entries = [dict(hand(None, 3, x, None, "auto"), role="application", pool="ichimatsu") for n, nm, x in ich]
ich_section = ([dict(hand("Ichimatsu", 3, Garden(["===|||"] * 3 + ["|||==="] * 3, (5, 0, "S")),
"Checkerboard blocks: rake each block across or down, as hatched.",
"Rake each block as three parallel lines, then step into the next block."), role="intro"), ich_entries[0]],
ich_entries[1:])
W[3] = [pair_entry("band", "A", 3, "Calm water", f"Rake this one as usual. {SAME}", "pair"),
pair_entry("band", "B", 3, "First wave", "The same garden, with a wave band: each row of the hatched band is crossed in one straight run, edge to edge.", "intro")]
waves = hand("Waves", 3, from_dict(L["waves"]["garden"]), None,
"Each row of the band is one straight run, so every turn happens in the plain sand at its ends.")
m, X[3] = arrange(gen(3), gen(3, combo=True), sections=[ich_section], showpiece=waves)
W[3] += m
# ---- world 4: grooves, introduced by a pair; then the finished ring and the pebbles, each its own short section
rings = sorted(pick("w4-ring", 2, lambda c: (c["tier"], c["trials"], c["cells"])), key=lambda c: (c["tier"], c["trials"], c["cells"]))
for c_ in rings:
c_.update(name=None, world=4, pool="ring", role="application")
ring_section = ([dict(hand("The finished ring", 4, PAIRS["ring"]["B"],
"A ring the abbot has already raked around a stone: leave it as it is, and rake around it."), role="intro", pair="ring"),
rings[0]], [rings[1]])
pebbles = sorted(gen(4, only={"w4-p66", "w4-p77", "w4-p78"}), key=lambda c: c["diff"])
for c_ in pebbles:
c_.update(pool="pebble", role="application")
pebble_section = ([pair_entry("pebbles", "A", 4, "His grooves", f"Another garden with the abbot's grooves. {SAME}", "pair"),
pair_entry("pebbles", "B", 4, "His pebbles",
"The same garden, with pebbles where the grooves were. Pale pebble: the line runs straight through it, either way. "
"Dark pebble: the line turns at it, either way.", "intro"),
pebbles[0], hand("The abbot's pebbles", 4, from_dict(mp["garden"]), None,
"A pebble beside a wall or a stone often leaves only one way through; settle those first.")],
pebbles[1:])
W[4] = [pair_entry("grooves", "A", 4, "Open ground", f"Many rakings fit this garden. {SAME}", "pair"),
pair_entry("grooves", "B", 4, "First grooves", "The same garden: the abbot began the raking. Your stroke must run along his grooves, and now only one raking fits.", "intro")]
masters = hand("The master's grooves", 4, from_dict(L["grooves"]["garden"]), None,
"A groove fixes how the stroke enters and leaves its cell, and often its neighbours too: extend each one first.")
grooves = gen(4, only={"w4-g66", "w4-g77", "w4-g78"})
for c_ in grooves:
c_["pool"] = "groove"
m, X[4] = arrange(grooves, gen(4, combo=True), sections=[ring_section, pebble_section], showpiece=masters)
W[4] += m
# ---- world 5: the wide rake, introduced by a pair whose every par raking turns back in open sand; Spiral closes it
wide_rake = hand_profile(hand("Wide rake", 5, gw, None, "Every reversal needs a pivot: each U-turn must hug a wall or wrap around a stone."), "strict")
spiral = hand("Spiral", 5, from_dict(w["garden"]), None,
"One stone stands in open sand: the only way to turn back around it is to spiral in toward it.")
W[5] = [pair_entry("wide", "A", 5, "Narrow rake", f"Rake this one with the ordinary rake. {SAME}", "pair"),
pair_entry("wide", "B", 5, "Wide blade", "The same garden, with the wide rake: it cannot turn back in open sand, since a U-turn needs a wall "
"or a stone to pivot on; and it is set down against one.", "intro")]
m, X[5] = arrange(gen(5) + [wide_rake], gen(5, combo=True), capstone=spiral)
W[5] += m
X[5] = [dict(wide_intro, name=None, world=5)] + X[5]
# ---- world 6: the side gate, introduced by a pair; combinations add pebbles; Side gate closes it
sg = hand("Side gate", 6, from_dict(t["garden"]), None,
"Both ends are fixed: plan backwards from the side gate as well as forwards from the veranda.")
W[6] = [pair_entry("side", "A", 6, "One gate", f"A garden for the wide rake. {SAME}", "pair"),
pair_entry("side", "B", 6, "Two gates", "The same garden, with a side gate: the stroke must begin there, and end at the veranda. "
"Of the first garden's rakings, only one begins there.", "intro")]
m, X[6] = arrange(gen(6), gen(6, combo=True), capstone=sg)
W[6] += m
X[6] = [dict(side_intro, name=None, world=6)] + X[6]
# ---- world 7: everything, larger; three-rule gardens are the combinations; Silver sand closes the game
silver = hand("Silver sand", 7, Garden(["".join("=" if ((r // 3) + (c // 3)) % 2 == 0 else "|" for c in range(9)) for r in range(9)], (8, 0, "S")),
None, "Nine blocks, each raked as three parallel lines: find the order in which each block hands the stroke to the next.")
apps7 = sorted(gen(7), key=lambda c: c["diff"])
first7 = dict(apps7.pop(0), role="intro", intro="Night: everything the day taught, in larger gardens.")
m, X[7] = arrange(apps7, gen(7, combo=True), capstone=silver)
W[7] = [first7] + m
for wld in X:
for e in X[wld]:
e["extra"] = True
e.setdefault("role", "application")
W = {wld: W[wld] + X[wld] for wld in W}
for wld in W:
for e in W[wld]:
e.setdefault("role", "application")
# names, and the final list: a garden keeps the name its layout had before (out/garden_names.json, seeded by
# seed_garden_names.py), so that names, poems and saved progress survive rearranging the campaign; a garden new to the
# campaign takes the next name of its pool that no other layout owns
def full_key(d):
return json.dumps([d["rows"], d["gate"], d["end"], d.get("end_side"), d["nohairpin"], bool(d["braced_start"]),
sorted(d["clues"].items()), sorted((d.get("marks") or {}).items())])
REG = {r["key"]: r["name"] for r in json.load(open(NAMES_REG))} if os.path.exists(NAMES_REG) else {}
owned = set(REG.values())
final = []
pools = {k: iter(v) for k, v in POOLS.items()}
for world in range(1, 8):
names = iter(NAMES[world])
taken = {e.get("name") for e in W[world] if e.get("name")}
for e in W[world]:
if "garden" in e and not isinstance(e["garden"], dict):
e["garden"] = to_dict(e["garden"])
if not e.get("name"):
nm = REG.get(full_key(e["garden"]))
if nm is None or nm in taken:
src = pools[e["pool"]] if e.get("pool") else names
nm = next(src)
while nm in taken or nm in owned:
nm = next(src)
e["name"] = nm
taken.add(nm)
final.append({"name": e["name"], "world": world, "garden": e["garden"], "intro": e.get("intro") if not e.get("extra") else None,
"hint": e.get("hint", "auto"), "tone": e.get("tone", 0.0), "role": e["role"],
"hint_cells": e.get("hint_cells"), "trap": round(e.get("trap", 0.0), 3),
"extra": bool(e.get("extra")), "pair": e.get("pair")})
stats[f"world{world}"] = len(W[world])
print(world, " | ".join(f"{e['name']} ({e['role'][:4]}{', x' if e.get('extra') else ''})" for e in W[world]), flush=True)
counts = [stats[f"world{w}"] for w in range(1, 8)]
print("gardens per world:", counts, "total", sum(counts))
stats["main"] = [sum(not e.get("extra") for e in W[w]) for w in range(1, 8)]
stats["extras"] = [sum(bool(e.get("extra")) for e in W[w]) for w in range(1, 8)]
print("main path per world:", stats["main"], "extras:", stats["extras"])
# the whole definition: a contrasting pair is one layout twice, differing only in its clues, pebbles or rake
def full_key(d):
return (tuple(d["rows"]), tuple(d["gate"]), tuple(d["end"] or ()), d.get("end_side"), d["nohairpin"], bool(d["braced_start"]),
tuple(sorted(d["clues"].items())), tuple(sorted((d.get("marks") or {}).items())))
seen_k = {}
for e in final:
k = full_key(e["garden"])
if k in seen_k:
print("duplicate:", seen_k[k], "=", e["name"], flush=True)
seen_k[k] = e["name"]
assert len({full_key(e["garden"]) for e in final}) == len(final), "duplicate gardens"
same = [e["name"] for e in final if e.get("pair") and e["role"] == "pair"]
stats["layouts"] = len({canon(from_dict(e["garden"])) for e in final})
json.dump(final, open(OUT, "w"))
stats["seconds"] = round(time.time() - t_start)
json.dump(stats, open(STATS, "w"), indent=1)
print("saved", OUT, f"{stats['seconds']}s")
if __name__ == "__main__":
main()
src/clues.py — Greedy 'master's grooves' clue selection: add target-solution glyphs until the garden has a unique raking.
"""Greedy 'master's grooves' clue selection: add target-solution glyphs until the garden has a unique raking."""
import random
from copy import deepcopy
from zg import Garden, glyphs
def with_clues(g, clues):
g2 = deepcopy(g)
g2.clues = dict(g.clues); g2.clues.update(clues)
return g2
def count(g, cap):
r = g.solve(mode=0, cap=cap, nodelimit=2 * 10**8)
return r["nsol"], r
def make_unique(g, target_draw, rng, cap=3000, max_clues=40, sample=24):
gl = glyphs(g, target_draw)
start = target_draw[-1]
cand_all = [c for c in gl if c != start and len(gl[c]) == 2 and c not in g.clues]
clues = {}
n, _ = count(g, cap)
while n > 1 and len(clues) < max_clues:
cand = [c for c in cand_all if c not in clues]
rng.shuffle(cand)
best, bestn = None, None
for c in cand[:sample]:
m, _ = count(with_clues(g, {**clues, c: gl[c]}), cap)
if bestn is None or m < bestn:
best, bestn = c, m
if m == 1: break
clues[best] = gl[best]
n = bestn
if n != 1:
return None
# prune redundant clues
for c in list(clues):
trial = {k: v for k, v in clues.items() if k != c}
m, _ = count(with_clues(g, trial), 2)
if m == 1:
clues = trial
return clues
def with_marks(g, marks):
g2 = deepcopy(g)
g2.marks = dict(g.marks); g2.marks.update(marks)
return g2
def make_unique_marks(g, target_draw, rng, cap=3000, max_marks=60, sample=24):
"""Like make_unique, but with the abbot's *marks*: each says only 'straight here' ('S') or 'turn here' ('B'),
taken from the target raking; returns None if marks alone cannot make the target unique."""
gl = glyphs(g, target_draw)
start = target_draw[-1]
kind = {c: ("S" if set(ds) in ({"E", "W"}, {"N", "S"}) else "B") for c, ds in gl.items() if len(ds) == 2}
cand_all = [c for c in kind if c != start and c not in g.clues and c not in g.marks]
marks = {}
n, _ = count(g, cap)
while n > 1 and len(marks) < max_marks:
cand = [c for c in cand_all if c not in marks]
if not cand:
break
rng.shuffle(cand)
best, bestn = None, None
for c in cand[:sample]:
m, _ = count(with_marks(g, {**marks, c: kind[c]}), cap)
if bestn is None or m < bestn:
best, bestn = c, m
if m == 1: break
marks[best] = kind[best]
n = bestn
if n != 1:
return None
for c in list(marks):
trial = {k: v for k, v in marks.items() if k != c}
m, _ = count(with_marks(g, trial), 2)
if m == 1:
marks = trial
return marks
src/daily.py — A year of daily gardens (366), the same for everyone on a given date, none of them in the campaign.
"""daily.py: a year of daily gardens (366), the same for everyone on a given date, none of them in the campaign.
Free and strict gardens alternate (free: scored against par; strict: exactly one raking, under grooves, pebbles, the
wide rake or a side gate), sizes 5x5 to 8x8, made by the campaign's own generators (campaign.make) and filters. The
candidates the campaign generated and did not use are taken first; the rest are generated here (cached in
out/daily_cache/). Each garden is exported as the prototype expects (export_proto.py's fields: par, raking counts,
every par raking as move strings for the in-page hints, an intended raking, a first observation). Writes
out/daily_gardens.json (day 0 = 2026-01-01; the page picks day-of-cycle from the local date) and out/daily_stats.json.
"""
from __future__ import annotations
import glob
import json
import math
import os
import pickle
import random
import sys
import time
from multiprocessing import Pool
from zg import turns_of, check_solution
from zcp import optimize
from levels import from_dict, to_dict
from campaign import canon, key_of, _run_spec, CACHE
from hints import best_free_hint, best_strict_hint, near_misses
OUT = "/home/claude/zen/out/daily_gardens.json"
STATS = "/home/claude/zen/out/daily_stats.json"
DCACHE = "/home/claude/zen/out/daily_cache"
YEAR = 366
MAX_AT_PAR = 24 # free gardens with more par rakings than this are left out (the page carries every par raking)
MOVE = {(-1, 0): "N", (1, 0): "S", (0, 1): "E", (0, -1): "W"}
def moves_of(d):
return "".join(MOVE[(b[0] - a[0], b[1] - a[1])] for a, b in zip(d, d[1:]))
FREE = [
dict(id="d-f66", gen="free", H=6, W=6, n=18), dict(id="d-f67", gen="free", H=6, W=7, n=20),
dict(id="d-f77", gen="free", H=7, W=7, n=22), dict(id="d-f78", gen="free", H=7, W=8, n=12),
dict(id="d-r66", gen="free", H=6, W=6, ripple=True, n=14), dict(id="d-r67", gen="free", H=6, W=7, ripple=True, n=18),
dict(id="d-r77", gen="free", H=7, W=7, ripple=True, n=20), dict(id="d-r78", gen="free", H=7, W=8, ripple=True, n=10),
dict(id="d-r77x2", gen="free", H=7, W=7, ripple=True, ripple2=True, n=8, budget=400),
dict(id="d-b67", gen="free", H=6, W=7, band=True, n=14), dict(id="d-b77", gen="free", H=7, W=7, band=True, n=18),
dict(id="d-b78", gen="free", H=7, W=8, band=True, n=10), dict(id="d-br77", gen="free", H=7, W=7, band=True, ripple=True, n=8, budget=400),
dict(id="d-f66b", gen="free", H=6, W=6, n=8), dict(id="d-r67b", gen="free", H=6, W=7, ripple=True, n=8), # (the first round fell 4 short after the par-raking cap)
]
STRICT = [
dict(id="d-g66", gen="grooves", H=6, W=6, n=16), dict(id="d-g77", gen="grooves", H=7, W=7, n=20),
dict(id="d-g78", gen="grooves", H=7, W=8, n=8, budget=400), dict(id="d-gr77", gen="grooves", H=7, W=7, ripple=True, n=6, budget=400),
dict(id="d-ring78", gen="grooves", H=7, W=8, closed_ring=True, n=6, budget=400),
dict(id="d-p66", gen="pebbles", H=6, W=6, n=12), dict(id="d-p77", gen="pebbles", H=7, W=7, n=12),
dict(id="d-p78", gen="pebbles", H=7, W=8, n=4, budget=300), dict(id="d-pb77", gen="pebbles", H=7, W=7, band=True, n=4, budget=300),
dict(id="d-w55", gen="wide", H=5, W=5, n=8), dict(id="d-w56", gen="wide", H=5, W=6, n=8),
dict(id="d-w66", gen="wide", H=6, W=6, n=14), dict(id="d-w67", gen="wide", H=6, W=7, n=14),
dict(id="d-w77", gen="wide", H=7, W=7, n=16), dict(id="d-w78", gen="wide", H=7, W=8, n=10), dict(id="d-w88", gen="wide", H=8, W=8, n=8),
dict(id="d-wr77", gen="wide", H=7, W=7, ripple=True, n=8), dict(id="d-wb77", gen="wide", H=7, W=7, band=True, n=8),
dict(id="d-s67", gen="side_wide", H=6, W=7, n=12), dict(id="d-s77", gen="side_wide", H=7, W=7, n=14),
dict(id="d-s78", gen="side_wide", H=7, W=8, n=10), dict(id="d-s88", gen="side_wide", H=8, W=8, n=6),
dict(id="d-sp77", gen="side_pebbles", H=7, W=7, n=6, budget=300),
]
for s in FREE + STRICT:
s.setdefault("over", 1)
s.setdefault("budget", 240)
def run_cached(args):
spec, seed = args
os.makedirs(DCACHE, exist_ok=True)
path = f"{DCACHE}/{spec['id']}.pkl"
if os.path.exists(path):
return pickle.load(open(path, "rb"))
res = _run_spec(spec, seed)
pickle.dump(res, open(path, "wb"))
return res
def leftovers(used):
"""Candidates the campaign generated and did not use (by layout up to symmetry)."""
out = []
for f in sorted(glob.glob(f"{CACHE}/w*.pkl")):
sid, found, tried, secs = pickle.load(open(f, "rb"))
for c in found:
if canon(c["garden"]) not in used and c["garden"].H <= 8 and c["garden"].W <= 8: # (the daily garden stays at 8x8 or smaller)
c = dict(c)
c["spec"] = "campaign:" + sid
out.append(c)
return out
EXPORTS = f"{DCACHE}/exports.pkl"
def export(c, k):
"""The prototype's garden record for candidate c (see export_proto.add), as day k; cached by layout."""
cache = pickle.load(open(EXPORTS, "rb")) if os.path.exists(EXPORTS) else {}
ck = (key_of(c["garden"]), tuple(sorted(to_dict(c["garden"])["clues"].items())), tuple(sorted(to_dict(c["garden"])["marks"].items())), c["garden"].nohairpin)
if ck in cache:
return cache[ck]
e = _export(c, k)
cache[ck] = e
pickle.dump(cache, open(EXPORTS, "wb"))
return e
def _export(c, k):
g = c["garden"]
intended = [tuple(x) for x in c["target"]]
assert check_solution(g, intended), c["spec"]
r = g.solve(mode=0, cap=10**9, nodelimit=4 * 10**9)
assert r["nsol"] > 0 and r["complete"] and not r["capped"], c["spec"]
if c["kind"] == "free":
free = optimize(g, time_limit=120)
assert free[1] == "OPTIMAL", c["spec"]
par = free[0]
else:
assert r["nsol"] == 1, c["spec"]
par = turns_of(g, intended)
assert turns_of(g, intended) == par, c["spec"]
at_par = r["hist"].get(par, 0)
assert at_par > 0, c["spec"]
if at_par > MAX_AT_PAR:
return None
rp = g.solve(mode=2, bound=par, cap=10**6, stream=True)
par_rakings = [s_["draw"] for s_ in rp["sols"]]
assert rp["complete"] and not rp["capped"] and len(par_rakings) == rp["nsol"] == at_par, c["spec"]
assert all(check_solution(g, d_) and turns_of(g, d_) == par for d_ in par_rakings), c["spec"]
par_rakings.sort(key=lambda d_: [tuple(x) for x in d_] != intended)
assert [tuple(x) for x in par_rakings[0]] == intended, c["spec"]
if r["nsol"] == 1:
hint, hint_cells, _ = best_strict_hint(g, intended)
else:
hint, hint_cells, _ = best_free_hint(g, par, par_rakings, near_misses(g, par))
d = to_dict(g)
return {"name": f"daily-{k:03d}", "world": 0, "rows": g.rows, "gate": list(g.gate), "end": list(g.end) if g.end else None,
"end_side": g.end_side, "clues": d["clues"], "wide": g.nohairpin == 2, "par": par, "rakings": r["nsol"], "at_par": at_par,
"intended": [list(x) for x in intended], "intro": None, "marks": d["marks"], "par_moves": [moves_of(d_) for d_ in par_rakings],
"hint": hint, "tone": 0.0, "hint_cells": hint_cells or [], "role": "daily", "trap": 0.0, "extra": False, "pair": None,
"demo": False, "kind": c["kind"], "spec": c["spec"]}
def main():
t0 = time.time()
C = json.load(open("/home/claude/zen/out/campaign.json"))
used = {canon(from_dict(e["garden"])) for e in C}
cands = leftovers(used)
print(f"{len(cands)} candidates left over from the campaign", flush=True)
specs = FREE + STRICT
with Pool(2) as pool:
results = pool.map(run_cached, [(s, 9000 + i) for i, s in enumerate(specs)])
stats = {}
for sid, found, tried, secs in results:
stats[sid] = {"tried": tried, "found": len(found), "seconds": round(secs)}
print(f"{sid:9s} tried {tried:6d} found {len(found):3d} in {secs:5.0f}s", flush=True)
cands += found
# one of each layout (up to symmetry), none from the campaign
seen, uniq = set(used), []
for c in cands:
cn = canon(c["garden"])
if cn in seen:
continue
seen.add(cn)
uniq.append(c)
free = [c for c in uniq if c["kind"] == "free"]
strict = [c for c in uniq if c["kind"] == "strict"]
print(f"{len(uniq)} distinct layouts: {len(free)} free, {len(strict)} strict", flush=True)
rng = random.Random(2026)
rng.shuffle(free)
rng.shuffle(strict)
half = YEAR // 2
days, picked = [], {"free": 0, "strict": 0}
for kind, pool_ in (("free", free), ("strict", strict)):
out = []
for c in pool_:
if len(out) == half:
break
e = export(c, 0)
if e is not None:
out.append(e)
print(f"{kind} {len(out):3d}/{half} {c['spec']:14s} {c['garden'].H}x{c['garden'].W} rakings={e['rakings']:>8,} par={e['par']:2d} at_par={e['at_par']:2d} {e['hint']}", flush=True)
assert len(out) == half, f"only {len(out)} {kind} gardens"
picked[kind] = out
for k in range(YEAR): # even days free, odd days strict
e = (picked["free"] if k % 2 == 0 else picked["strict"])[k // 2]
e["name"] = f"daily-{k:03d}"
days.append(e)
assert len({(tuple(e["rows"]), tuple(e["gate"]), tuple(e["end"] or []), e["wide"], json.dumps(e["clues"], sort_keys=True), json.dumps(e["marks"], sort_keys=True)) for e in days}) == YEAR
json.dump(days, open(OUT, "w"))
sizes = {}
for e in days:
sizes[f"{len(e['rows'])}x{len(e['rows'][0])}"] = sizes.get(f"{len(e['rows'])}x{len(e['rows'][0])}", 0) + 1
kinds = {}
for e in days:
kinds[e["spec"].split(":")[-1][:5] if e["spec"].startswith("campaign") else e["spec"]] = kinds.get(e["spec"].split(":")[-1][:5] if e["spec"].startswith("campaign") else e["spec"], 0) + 1
stats_out = {"days": YEAR, "free": half, "strict": YEAR - half, "sizes": sizes, "from_campaign_leftovers": sum(e["spec"].startswith("campaign") for e in days),
"with_ripple": sum(any("O" in r for r in e["rows"]) for e in days), "with_band": sum(any(ch in r for r in e["rows"] for ch in "hv") for e in days),
"with_grooves": sum(bool(e["clues"]) for e in days), "with_pebbles": sum(bool(e["marks"]) for e in days),
"wide": sum(e["wide"] for e in days), "side_gate": sum(bool(e["end"]) for e in days), "closed_ring": sum(any("R" in r for r in e["rows"]) for e in days),
"max_at_par": MAX_AT_PAR, "bytes": os.path.getsize(OUT), "specs": stats, "seconds": round(time.time() - t0)}
json.dump(stats_out, open(STATS, "w"), indent=1)
print(json.dumps({k: v for k, v in stats_out.items() if k != "specs"}, indent=1), flush=True)
if __name__ == "__main__":
main()
src/exp_clues.py
import random, statistics, sys, time
from multiprocessing import Pool
from gen import random_layout
from clues import make_unique
def job(args):
seed, H, W, k, variant = args
rng = random.Random(seed * 31 + k)
g = random_layout(H, W, k, rng, gate_side="S")
if g is None: return None
if variant == "wide":
g.nohairpin = 2; g.braced_start = True
r = g.solve(mode=0, cap=1, nodelimit=10**8)
if r["nsol"] == 0: return None
target = r["sols"][0]["draw"]
clues = make_unique(g, target, rng)
if clues is None: return ("fail",)
return (len(clues), len(g.sand_cells()))
if __name__ == "__main__":
H, W = int(sys.argv[1]), int(sys.argv[2])
for variant in ["plain", "wide"]:
for k in [0, 2, 4]:
t0 = time.time()
with Pool(2) as p:
res = [x for x in p.map(job, [(s, H, W, k, variant) for s in range(120)]) if x]
ok = [x for x in res if x[0] != "fail"]
nc = [x[0] for x in ok]
print(f"{variant:5s} {H}x{W} k={k}: n={len(ok)} fails={len(res)-len(ok)} clues median={statistics.median(nc) if nc else 0} "
f"mean={statistics.mean(nc) if nc else 0:.1f} max={max(nc) if nc else 0} frac0={sum(c==0 for c in nc)/max(1,len(nc)):.2f} "
f"clues/cell={statistics.mean(c/x[1] for c, x in zip(nc, ok)) if nc else 0:.3f} ({time.time()-t0:.0f}s)", flush=True)
src/exp_constraints.py
import random, statistics, time
from multiprocessing import Pool
from zg import Garden
def place_ripples(H, W, n_ripple, n_plain, rng):
rows = [["."] * W for _ in range(H)]
tries = 0; placed = 0
while placed < n_ripple and tries < 500:
tries += 1
r, c = rng.randint(1, H - 2), rng.randint(1, W - 2)
if all(rows[a][b] == "." for a in range(r - 1, r + 2) for b in range(c - 1, c + 2)):
# keep other ripple rings disjoint-ish: require 3x3 free
rows[r][c] = "O"; placed += 1
placed = 0; tries = 0
while placed < n_plain and tries < 500:
tries += 1
r, c = rng.randrange(H), rng.randrange(W)
if rows[r][c] != ".": continue
# don't break any ripple ring
if any(0 <= r+dr < H and 0 <= c+dc < W and rows[r+dr][c+dc] == "O" for dr in (-1,0,1) for dc in (-1,0,1)): continue
rows[r][c] = "#"; placed += 1
gc = rng.choice([c for c in range(W) if rows[H-1][c] == "."])
return Garden(["".join(x) for x in rows], (H - 1, gc, "S"))
def place_band(H, W, rng, orient):
rows = [["."] * W for _ in range(H)]
if orient == "h":
h = rng.randint(2, 3); w = rng.randint(3, W - 2)
r0 = rng.randint(0, H - h - 1); c0 = rng.randint(1, W - w - 1)
else:
w = rng.randint(2, 3); h = rng.randint(3, H - 2)
r0 = rng.randint(1, H - h - 1); c0 = rng.randint(0, W - w)
for r in range(r0, r0 + h):
for c in range(c0, c0 + w):
rows[r][c] = orient
# one or two plain stones away from band
for _ in range(rng.randint(0, 2)):
r, c = rng.randrange(H), rng.randrange(W)
if rows[r][c] == ".": rows[r][c] = "#"
gcs = [c for c in range(W) if rows[H-1][c] == "."]
if not gcs: return None
return Garden(["".join(x) for x in rows], (H - 1, rng.choice(gcs), "S"))
def job(args):
kind, seed, H, W = args
rng = random.Random(seed)
if kind.startswith("ripple"):
n = int(kind[-1])
g = place_ripples(H, W, n, rng.randint(0, 2), rng)
else:
g = place_band(H, W, rng, kind[-1])
if g is None: return None
r = g.solve(mode=0, cap=200000, nodelimit=3*10**8)
return r["nsol"]
if __name__ == "__main__":
for kind in ["ripple1", "ripple2", "band_h", "band_v"]:
for (H, W) in [(7, 7), (8, 8)]:
t0 = time.time()
with Pool(2) as p:
res = [x for x in p.map(job, [(kind, s, H, W) for s in range(300)]) if x is not None]
solv = [x for x in res if x > 0]
print(f"{kind:8s} {H}x{W}: solvable={len(solv)/len(res):.3f} unique={sum(x==1 for x in res)/len(res):.3f} "
f"1-20={sum(1<=x<=20 for x in res)/len(res):.3f} median={statistics.median(solv) if solv else 0:g} ({time.time()-t0:.0f}s)", flush=True)
src/exp_continuity.py — Does the abbot's taste in worlds 1-3 match the wide rake's physics (worlds 5-7)?
"""Does the abbot's taste in worlds 1-3 match the wide rake's physics (worlds 5-7)?
The generator keeps a free garden only if *some* par raking has no U-turn in open sand. Astra's review pointed out that
this does not make *every* rewarded raking graceful. For each free garden of worlds 1-3 in the exported campaign, count
the par rakings (all of them are stored) with and without an open-sand U-turn. Writes out/continuity.json.
"""
import json
from zg import Garden, hairpin_list
P = json.load(open('/home/claude/zen/out/proto_gardens.json'))
STEP = {"N": (-1, 0), "S": (1, 0), "E": (0, 1), "W": (0, -1)}
def garden_of(p):
marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()}
clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()}
return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None,
nohairpin=2 if p['wide'] else 0, braced_start=p['wide'], end_side=p['end_side'])
def cells(p, ms):
r, c = p['gate'][0], p['gate'][1]
out = [(r, c)]
for ch in ms:
r, c = r + STEP[ch][0], c + STEP[ch][1]
out.append((r, c))
return out
rows = []
for p in P:
if p['world'] > 3 or p['rakings'] == 1:
continue
g = garden_of(p)
opened = [any(not braced for *_, braced in hairpin_list(g, cells(p, ms))) for ms in p['par_moves']]
rows.append({"name": p["name"], "world": p["world"], "extra": p["extra"], "role": p["role"], "at_par": len(opened),
"with_open_u": sum(opened)})
mixed = [r for r in rows if 0 < r["with_open_u"] < r["at_par"]]
clean = [r for r in rows if r["with_open_u"] == 0]
most = [r for r in rows if r["with_open_u"] * 2 > r["at_par"]]
out = {"free_gardens": len(rows), "all_graceful": len(clean), "mixed": len(mixed), "majority_open_u": len(most),
"none_graceful": sum(r["with_open_u"] == r["at_par"] for r in rows),
"par_rakings": sum(r["at_par"] for r in rows), "par_rakings_with_open_u": sum(r["with_open_u"] for r in rows),
"worst": max(rows, key=lambda r: (r["with_open_u"] / r["at_par"], r["at_par"])), "gardens": rows}
json.dump(out, open('/home/claude/zen/out/continuity.json', 'w'), indent=1)
print({k: v for k, v in out.items() if k != "gardens"})
src/exp_graceful.py
import random, statistics, time, sys
from gen import random_layout, parity_ok
from zcp import optimize
rng = random.Random(int(sys.argv[1]))
rows = []
t0 = time.time()
while len(rows) < int(sys.argv[2]):
H, W = rng.choice([(6,7),(7,7),(7,8)])
g = random_layout(H, W, rng.choice([1,2,3]), rng, gate_side="S")
if g is None or not parity_ok(g): continue
if g.solve(mode=0, cap=1)['nsol'] == 0: continue
t_free = optimize(g, time_limit=60)
if t_free[1] != 'OPTIMAL': continue
t_grace = optimize(g, nohairpin=2, time_limit=60)
g.braced_start = True
t_wide = optimize(g, nohairpin=2, time_limit=60)
rows.append((t_free[0], t_free[4], t_grace[0], t_grace[1], t_wide[0], t_wide[1]))
free = [r[0] for r in rows]
same = sum(1 for r in rows if r[2] == r[0])
feas_grace = sum(1 for r in rows if r[2] is not None)
feas_wide = sum(1 for r in rows if r[4] is not None)
gaps = [r[2] - r[0] for r in rows if r[2] is not None]
gapw = [r[4] - r[0] for r in rows if r[4] is not None]
hp_at_par = [r[1] for r in rows]
print(f"n={len(rows)} ({time.time()-t0:.0f}s)")
print(f"graceful (no open hairpin) feasible: {feas_grace/len(rows):.2f}; graceful optimum == free par: {same/len(rows):.2f}; gap when feasible: mean {statistics.mean(gaps):.2f} max {max(gaps)}")
print(f"wide rake feasible: {feas_wide/len(rows):.2f}; gap mean {statistics.mean(gapw) if gapw else float('nan'):.2f}")
print("hairpins in (one) free-par solution: mean", statistics.mean(hp_at_par))
src/exp_marks.py — The abbot's marks vs the master's grooves: on the same gardens and the same intended raking (a graceful par
"""The abbot's marks vs the master's grooves: on the same gardens and the same intended raking (a graceful par
raking), how many clues does each need for uniqueness, and how hard does the grader find the result?"""
import json, random, statistics, sys, time
from multiprocessing import Pool
from gen import random_layout, parity_ok
from zg import turns_of
from zcp import optimize
from clues import make_unique, with_clues, make_unique_marks, with_marks
from grader import grade
BUDGET = float(sys.argv[1]) if len(sys.argv) > 1 else 600
def job(seed):
rng = random.Random(seed); out = []; t0 = time.time()
while time.time() - t0 < BUDGET:
H, W = rng.choice([(7, 7), (7, 8)])
g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0:
continue
free = optimize(g, time_limit=30); grace = optimize(g, nohairpin=2, time_limit=30)
if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]:
continue
target = list(reversed(grace[2]))
s = rng.randrange(10**9)
grooves = make_unique(g, target, random.Random(s))
marks = make_unique_marks(g, target, random.Random(s))
row = {"rows": g.rows, "gate": list(g.gate), "cells": len(target), "turns": turns_of(g, target),
"target": [list(c) for c in target]}
if grooves is not None:
gr = grade(with_clues(g, grooves))
row.update(grooves={f"{r},{c}": v for (r, c), v in grooves.items()}, g_tier=gr["tier"], g_trials=gr["trials"])
if marks is not None:
gr = grade(with_marks(g, marks))
row.update(marks={f"{r},{c}": v for (r, c), v in marks.items()}, m_tier=gr["tier"], m_trials=gr["trials"])
out.append(row)
return out
if __name__ == "__main__":
with Pool(2) as p:
rows = [x for r in p.map(job, [2000, 2001]) for x in r]
json.dump(rows, open("/home/claude/zen/out/marks_vs_grooves.json", "w"))
both = [r for r in rows if "grooves" in r and "marks" in r]
print(f"gardens {len(rows)}; grooves ok {sum('grooves' in r for r in rows)}; marks ok {sum('marks' in r for r in rows)}; both {len(both)}")
if both:
print("clues median: grooves", statistics.median(len(r["grooves"]) for r in both), "marks", statistics.median(len(r["marks"]) for r in both))
for k in ("g", "m"):
tiers = [r[f"{k}_tier"] for r in both]
print(k, "tiers", {t: tiers.count(t) for t in sorted(set(tiers))})
src/exp_open.py
import time
from zg import Garden
for (H, W) in [(3,3),(3,4),(4,4),(4,5),(5,5),(5,6),(6,6)]:
for gate in [((H-1, 0, "W"), "corner"), ((H-1, W//2, "S"), "mid-edge")]:
g = Garden(["."*W]*H, gate[0])
t0 = time.time()
r = g.solve(mode=0, cap=10**12, timeout=3600)
dt = time.time()-t0
h = r['hist']; n = r['nsol']
tmin = min(h) if h else None; tmax = max(h) if h else None
frac_par = h[tmin]/n if n else 0
hp = r['hphist']
print(f"{H}x{W} {gate[1]:8s} n={n:>10} complete={r['complete']} minT={tmin} maxT={tmax} atPar={h.get(tmin,0)} ({frac_par:.4f}) zeroHairpin={hp.get(0,0)} nodes={r['nodes']} {dt:.1f}s", flush=True)
src/exp_open2.py
import time
from zg import Garden
for (H, W) in [(6,7),(7,7),(6,8)]:
for gate in [((H-1, 0, "W"), "corner")]:
g = Garden(["."*W]*H, gate[0])
t0 = time.time()
r = g.solve(mode=0, cap=10**12, timeout=3600)
dt = time.time()-t0
h = r['hist']; n = r['nsol']
tmin = min(h) if h else None; tmax = max(h) if h else None
print(f"{H}x{W} {gate[1]:8s} n={n:>10} complete={r['complete']} minT={tmin} maxT={tmax} atPar={h.get(tmin,0)} ({h.get(tmin,0)/n:.5f}) zeroHairpin={r['hphist'].get(0,0)} nodes={r['nodes']} {dt:.1f}s", flush=True)
print(' hist', sorted(h.items()), flush=True)
src/exp_pipeline.py — Run the generation pipeline in bulk and record yield / difficulty statistics per mechanic.
"""Run the generation pipeline in bulk and record yield / difficulty statistics per mechanic."""
import json, random, statistics, sys, time
from multiprocessing import Pool
from gen import random_layout, parity_ok
from zg import turns_of, hairpin_list, Garden
from zcp import optimize
from clues import make_unique, with_clues
from grader import grade
def grooves_job(seed):
rng = random.Random(seed); out = []; t0 = time.time(); tried = 0
while time.time() - t0 < float(sys.argv[2]):
H, W = rng.choice([(7, 7), (7, 8)])
g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
if g is None: continue
tried += 1
if not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0: continue
free = optimize(g, time_limit=30); grace = optimize(g, nohairpin=2, time_limit=30)
if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]: continue
target = list(reversed(grace[2]))
clues = make_unique(g, target, rng)
if clues is None: continue
gr = grade(with_clues(g, clues))
out.append({"cells": len(target), "clues": len(clues), "tier": gr["tier"], "trials": gr["trials"],
"dens": turns_of(g, target) / len(target)})
return out, tried
def wide_job(seed):
rng = random.Random(seed); out = []; t0 = time.time(); tried = 0
while time.time() - t0 < float(sys.argv[2]):
H, W = rng.choice([(7, 7), (7, 8), (8, 8)])
g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
if g is None: continue
tried += 1
if not parity_ok(g): continue
g.nohairpin = 2; g.braced_start = True
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1: continue
d = r["sols"][0]["draw"]
dens = turns_of(g, d) / len(d)
gr = grade(g)
out.append({"cells": len(d), "clues": 0, "tier": gr["tier"], "trials": gr["trials"], "dens": dens})
return out, tried
if __name__ == "__main__":
kind = sys.argv[1]
fn = grooves_job if kind == "grooves" else wide_job
with Pool(2) as p:
res = p.map(fn, [1000 + i for i in range(2)])
rows = [x for r, _ in res for x in r]; tried = sum(t for _, t in res)
print(f"{kind}: layouts tried {tried}, levels {len(rows)} (yield {len(rows)/tried:.3f})")
if not rows: sys.exit()
for t in sorted(set(r['tier'] for r in rows)):
sub = [r for r in rows if r['tier'] == t]
print(f" tier {t}: {len(sub)/len(rows):.2f} median trials {statistics.median([r['trials'] or 0 for r in sub])}")
print(f" clues median {statistics.median(r['clues'] for r in rows)} mean {statistics.mean(r['clues'] for r in rows):.1f}")
pretty = [r for r in rows if r['dens'] <= 0.40]
print(f" turn density median {statistics.median(r['dens'] for r in rows):.3f}; share with density<=0.40: {len(pretty)/len(rows):.2f}")
json.dump(rows, open(f"/home/claude/zen/out/pipeline_{kind}.json", "w"))
src/exp_sampler.py — Yields of the garden-like stone sampler (garden_sampler.py) against the single-cell sampler used in all earlier
"""Yields of the garden-like stone sampler (garden_sampler.py) against the single-cell sampler used in all earlier
experiments (gen.random_layout, 1-3 1x1 stones kept apart), on 7x7 gardens with the gate on the south wall:
free: parity, solvable, and par reachable with no U-turn in open sand ("graceful"); plus rakings at par;
grooves: grooves added from the graceful par raking until unique (count, grade);
wide: unique under the wide rake (yield, grade);
side: unique under the wide rake with a random side gate (yield).
Writes out/sampler_yields.json."""
import json, random, statistics, sys, time
from multiprocessing import Pool
from gen import random_layout, parity_ok
from garden_sampler import garden_layout, add_side_gate
from zcp import optimize
from clues import make_unique, with_clues
from grader import grade
N = int(sys.argv[1]) if len(sys.argv) > 1 else 300
def layout(kind, rng):
if kind == "single":
return random_layout(7, 7, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
return garden_layout(7, 7, rng)
def job(args):
kind, seed = args
rng = random.Random(seed)
out = {"kind": kind, "layouts": 0, "parity": 0, "solvable": 0, "graceful": 0, "at_par": [], "grooves": [], "g_tiers": [],
"wide_unique": 0, "wide_tiers": [], "side_unique": 0, "stone_cells": []}
while out["layouts"] < N:
g = layout(kind, rng)
if g is None:
continue
out["layouts"] += 1
out["stone_cells"].append(sum(ch == "#" for row in g.rows for ch in row))
if not parity_ok(g):
continue
out["parity"] += 1
r = g.solve(mode=0, cap=10**6, nodelimit=5 * 10**8)
if r["nsol"] == 0:
continue
out["solvable"] += 1
free = optimize(g, time_limit=20); grace = optimize(g, nohairpin=2, time_limit=20)
if free[1] == "OPTIMAL" and grace[1] == "OPTIMAL" and grace[0] == free[0]:
out["graceful"] += 1
if r["complete"] and not r["capped"]:
out["at_par"].append(r["hist"].get(free[0], 0))
if len(out["grooves"]) < 25:
cl = make_unique(g, list(reversed(grace[2])), rng)
if cl is not None:
out["grooves"].append(len(cl)); out["g_tiers"].append(grade(with_clues(g, cl))["tier"])
gw = Garden_copy(g); gw.nohairpin = 2; gw.braced_start = True
rw = gw.solve(mode=0, cap=2)
if rw["nsol"] == 1:
out["wide_unique"] += 1
out["wide_tiers"].append(grade(gw)["tier"])
gs = Garden_copy(gw)
if add_side_gate(gs, rng) and gs.solve(mode=0, cap=2)["nsol"] == 1:
out["side_unique"] += 1
return out
def Garden_copy(g):
from copy import deepcopy
return deepcopy(g)
if __name__ == "__main__":
t0 = time.time()
with Pool(2) as p:
res = p.map(job, [("single", 11), ("garden", 12)])
summary = {}
for o in res:
k = o["kind"]; n = o["layouts"]
summary[k] = {"layouts": n, "parity_ok": round(o["parity"] / n, 3), "solvable": round(o["solvable"] / n, 3),
"graceful_free": round(o["graceful"] / n, 3), "median_at_par": statistics.median(o["at_par"]) if o["at_par"] else None,
"median_grooves": statistics.median(o["grooves"]) if o["grooves"] else None,
"grooves_lookahead": round(sum(t >= 3 for t in o["g_tiers"]) / max(1, len(o["g_tiers"])), 2),
"wide_unique": round(o["wide_unique"] / n, 3),
"wide_nested": round(sum(t >= 4 for t in o["wide_tiers"]) / max(1, len(o["wide_tiers"])), 2),
"side_unique": round(o["side_unique"] / n, 3), "mean_stone_cells": round(statistics.mean(o["stone_cells"]), 2)}
print(k, summary[k], flush=True)
json.dump({"summary": summary, "raw": res}, open("/home/claude/zen/out/sampler_yields.json", "w"))
print(f"{time.time() - t0:.0f}s")
src/exp_variants.py
import random, sys, time, statistics, json
from multiprocessing import Pool
from gen import random_layout, parity_ok
from zg import Garden
VARIANTS = {
"plain": dict(nohairpin=0, braced_turns=False),
"nohairpin": dict(nohairpin=1, braced_turns=False),
"braced_hairpin": dict(nohairpin=2, braced_turns=False),
"braced_turns": dict(nohairpin=0, braced_turns=True),
}
CAP = 200000
def job(args):
seed, H, W, k = args
rng = random.Random(seed)
g = random_layout(H, W, k, rng, gate_side="S")
if g is None: return None
out = {"parity": parity_ok(g)}
for name, kw in VARIANTS.items():
g.nohairpin = kw["nohairpin"]; g.braced_turns = kw["braced_turns"]
r = g.solve(mode=0, cap=CAP, nodelimit=3 * 10**8)
out[name] = (r["nsol"], r["complete"], r["capped"])
return out
if __name__ == "__main__":
H, W, k, n = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
t0 = time.time()
with Pool(2) as p:
res = [r for r in p.map(job, [(s, H, W, k) for s in range(n)]) if r]
print(f"== {H}x{W}, {k} stones, gate on S edge, {len(res)} layouts ({time.time()-t0:.0f}s)")
print(f" parity-feasible: {sum(r['parity'] for r in res)/len(res):.2f}")
for name in VARIANTS:
counts = [r[name][0] for r in res]
capped = sum(r[name][2] for r in res)
solv = [c for c in counts if c > 0]
uniq = sum(c == 1 for c in counts)
few = sum(1 <= c <= 10 for c in counts)
med = statistics.median(solv) if solv else 0
print(f" {name:15s} solvable={len(solv)/len(res):.3f} unique={uniq/len(res):.3f} 1-10 sols={few/len(res):.3f} median#(solvable)={med:g} capped@{CAP}={capped}")
src/exp_variants2.py
import random, sys, time, statistics
from multiprocessing import Pool
from gen import random_layout, parity_ok
VARIANTS = {
"plain": dict(nohairpin=0, braced_start=False),
"braced_hp": dict(nohairpin=2, braced_start=False),
"wide_rake": dict(nohairpin=2, braced_start=True),
}
CAP = 200000
def job(args):
seed, H, W, k = args
rng = random.Random(seed * 7919 + k)
g = random_layout(H, W, k, rng, gate_side="S")
if g is None: return None
out = {}
for name, kw in VARIANTS.items():
g.nohairpin = kw["nohairpin"]; g.braced_start = kw["braced_start"]
r = g.solve(mode=0, cap=CAP, nodelimit=3 * 10**8)
out[name] = (r["nsol"], r["capped"])
return out
if __name__ == "__main__":
H, W, n = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3])
for k in [0, 1, 2, 3, 4, 6]:
t0 = time.time()
with Pool(2) as p:
res = [r for r in p.map(job, [(s, H, W, k) for s in range(n)]) if r]
line = f"{H}x{W} k={k} ({time.time()-t0:.0f}s):"
for name in VARIANTS:
counts = [r[name][0] for r in res]
solv = [c for c in counts if c > 0]
uniq = sum(c == 1 for c in counts)
few = sum(1 <= c <= 20 for c in counts)
med = statistics.median(solv) if solv else 0
line += f" {name}: solv={len(solv)/len(res):.3f} uniq={uniq/len(res):.3f} 1-20={few/len(res):.3f} med={med:g}"
print(line, flush=True)
src/exp_wide_par.py — How often is a wide-rake-unique garden's only raking also a par raking of the bare garden?
"""How often is a wide-rake-unique garden's only raking also a par raking of the bare garden?"""
import random, time, statistics, sys
from gen import random_layout, parity_ok
from zg import Garden, turns_of
from zcp import optimize
rng = random.Random(int(sys.argv[1]))
rows = []; t0 = time.time()
while time.time() - t0 < float(sys.argv[2]):
H, W = rng.choice([(7, 7), (7, 8), (8, 8)])
g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g): continue
g.nohairpin = 2; g.braced_start = True
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1: continue
t_wide = r["sols"][0]["turns"]
p = optimize(Garden(g.rows, g.gate), time_limit=60, workers=2)
if p[1] != "OPTIMAL": continue
rows.append((t_wide, p[0], t_wide / len(r["sols"][0]["draw"])))
gaps = [a - b for a, b, _ in rows]
print(f"n={len(rows)}; wide answer == bare par: {sum(g == 0 for g in gaps)/len(rows):.2f}; within 1: {sum(g <= 1 for g in gaps)/len(rows):.2f}; within 2: {sum(g <= 2 for g in gaps)/len(rows):.2f}; median gap {statistics.median(gaps)}")
calm = [r for r in rows if r[2] <= 0.40]
cg = [a - b for a, b, _ in calm]
print(f"among turn density <= 0.40 (n={len(calm)}): == par {sum(g == 0 for g in cg)/max(1,len(calm)):.2f}; within 1: {sum(g <= 1 for g in cg)/max(1,len(calm)):.2f}")
src/export_proto.py — Export the campaign (out/campaign.json, from campaign.py) for the prototype, re-deriving and checking everything:
"""Export the campaign (out/campaign.json, from campaign.py) for the prototype, re-deriving and checking everything:
each garden's raking count (exact solver), par (CP-SAT), rakings at par, every par raking (as move strings, for the
in-page hints), an intended raking (asserted valid and at par), and the first hints marked "auto" (hints.py)."""
import json
from zg import Garden, turns_of, check_solution
from zcp import optimize
from levels import from_dict, to_dict
import copy
from hints import hint_parity, far_cell, best_free_hint, best_strict_hint, near_misses, with_fact, first_dir, STRAIGHT_IN, gate_word
from grader import grade
from clues import with_marks
CAMPAIGN = json.load(open('/home/claude/zen/out/campaign.json'))
MOVE = {(-1, 0): "N", (1, 0): "S", (0, 1): "E", (0, -1): "W"}
def moves_of(d):
return "".join(MOVE[(b[0] - a[0], b[1] - a[1])] for a, b in zip(d, d[1:]))
gardens = []
HINT_STATS = []
# the gardens that bring in something new are first raked once by the monk, the calm way, and then reset for the player:
# the first garden (what raking is), the first one scored by turns, and the introduction of each later rule (the second
# garden of each contrasting pair, and the two rules introduced mid-world without a pair)
DEMO = {"The corridor", "Nine squares", "Ichimatsu", "The finished ring"}
def demo_of(name, role, pair, extra):
return not extra and (name in DEMO or (role == "intro" and pair is not None))
def add(name, world, g, intro=None, hint=None, tone=0.0, hint_cells=None, role=None, trap=0.0, extra=False, pair=None):
r = g.solve(mode=0, cap=10**9, nodelimit=4 * 10**9)
assert r['nsol'] > 0 and r['complete'] and not r['capped'], name
free = optimize(g, time_limit=120)
assert free[1] == 'OPTIMAL', name
par = free[0]
at_par = r['hist'].get(par, 0) # how many of the valid rakings reach par (exact: the count is complete)
assert at_par > 0, name
# intended raking: graceful par if available, else par
grace = optimize(g, nohairpin=2, time_limit=120) if not g.nohairpin else free
intended = list(reversed(grace[2])) if (grace[1] == 'OPTIMAL' and grace[0] == par) else list(reversed(free[2]))
assert check_solution(g, intended) and turns_of(g, intended) == par, name
# every par raking (the in-page hint decides "can this route still reach par?" exactly, by prefix)
rp = g.solve(mode=2, bound=par, cap=10**6, stream=True)
par_rakings = [s_["draw"] for s_ in rp["sols"]]
assert rp["complete"] and not rp["capped"] and len(par_rakings) == rp["nsol"] == at_par, name
assert all(check_solution(g, d_) and turns_of(g, d_) == par for d_ in par_rakings), name
par_rakings.sort(key=lambda d_: d_ != intended) # the intended raking first
assert par_rakings[0] == intended, name
if role == "trap" and r['nsol'] == 1:
# a strict trap teaches its trap: the only raking turns aside at once, though walking straight in looks fine
gate = (g.gate[0], g.gate[1])
assert first_dir(intended) != STRAIGHT_IN[g.gate[2]], name
hint, hint_cells = f"Walking straight in from the {gate_word(g)} goes wrong here: the line turns at once.", [list(gate)]
before, after = grade(g), grade(with_fact(g, hint_cells, hint))
assert after["solved"], name
HINT_STATS.append({"name": name, "kind": "strict", "trap": True, "tier": before["tier"], "tier_after": after["tier"],
"trials": before["trials"] or 0, "trials_after": after["trials"] or 0})
elif hint in (None, "auto", "auto-parity"):
if hint == "auto-parity":
hint = hint_parity(g, [s_["draw"] for s_ in g.solve(mode=0, cap=10**6, stream=True)["sols"]])
hint_cells = [list(far_cell(g))]
elif r['nsol'] == 1:
# the fact about the unique raking that lets the grader's tier 1-2 rules decide the most; with it as a
# given, how hard is the garden still?
hint, hint_cells, (d0, d1) = best_strict_hint(g, intended)
before = grade(g)
after = grade(with_fact(g, hint_cells, hint)) if hint_cells else before
assert after["solved"], name
HINT_STATS.append({"name": name, "kind": "strict", "tier": before["tier"], "tier_after": after["tier"],
"trials": before["trials"] or 0, "trials_after": after["trials"] or 0, "decided": [d0, d1]})
else:
near = near_misses(g, par)
hint, hint_cells, miss = best_free_hint(g, par, par_rakings, near)
HINT_STATS.append({"name": name, "kind": "free", "near": len(near), "miss": miss})
d = to_dict(g)
gardens.append({"name": name, "world": world, "rows": g.rows, "gate": list(g.gate), "end": list(g.end) if g.end else None,
"end_side": g.end_side, "clues": d["clues"], "wide": g.nohairpin == 2, "par": par,
"rakings": r['nsol'], "at_par": at_par, "intended": [list(c) for c in intended], "intro": intro,
"marks": d["marks"], "par_moves": [moves_of(d_) for d_ in par_rakings], "hint": hint, "tone": tone,
"hint_cells": hint_cells or [], "role": role, "trap": trap, "extra": bool(extra), "pair": pair,
"demo": demo_of(name, role, pair, extra)})
print(f"{world} {name:22s} {g.H}x{g.W} rakings={r['nsol']:>9,} par={par:2d} at_par={at_par:3d} {hint}", flush=True)
for e in CAMPAIGN:
add(e["name"], e["world"], from_dict(e["garden"]), e.get("intro"), e.get("hint"), e.get("tone", 0.0),
e.get("hint_cells"), e.get("role"), e.get("trap", 0.0), e.get("extra", False), e.get("pair"))
assert len({g["name"] for g in gardens}) == len(gardens), "duplicate names"
json.dump(gardens, open('/home/claude/zen/out/proto_gardens.json', 'w'))
json.dump(HINT_STATS, open('/home/claude/zen/out/hint_stats.json', 'w'), indent=1)
assert all(g["name"] in {x["name"] for x in gardens} for g in [{"name": n} for n in DEMO]), "a demonstration garden is missing"
print(len(gardens), "gardens exported;", sum(not g["extra"] for g in gardens), "on the main path;",
sum(g["demo"] for g in gardens), "with a demonstration:", ", ".join(g["name"] for g in gardens if g["demo"]))
src/fig_pebbles.py — The abbot's pebbles figure (same garden and answer: grooves vs pebbles), plus the marks-vs-grooves
"""fig_pebbles.py: the abbot's pebbles figure (same garden and answer: grooves vs pebbles), plus the marks-vs-grooves
statistics quoted in the document (from out/marks_vs_grooves.json, written by exp_marks.py)."""
import json, os, statistics
from zg import Garden, turns_of, check_solution
from levels import from_dict
from grader import grade
from render import garden_svg, hstack, save
FIG = "/home/claude/zen/fig/final"; PREFIX = "2026-10-03-gwern-samon-"
mp = json.load(open("/home/claude/zen/out/marks_pick.json"))
gm = from_dict(mp["garden"])
draw = [tuple(c) for c in mp["draw"]]
grooves = {tuple(map(int, k.split(","))): v for k, v in mp["grooves"].items()}
gg = Garden(gm.rows, gm.gate, clues=grooves)
plain = Garden(gm.rows, gm.gate)
rm, rg, rp = gm.solve(mode=0, cap=5), gg.solve(mode=0, cap=5), plain.solve(mode=0, cap=10**8)
assert rm["nsol"] == 1 and rg["nsol"] == 1 and check_solution(gm, draw) and check_solution(gg, draw)
grm, grg = grade(gm), grade(gg)
facts = {"pebbles": len(gm.marks), "pale": sum(v == "S" for v in gm.marks.values()), "dark": sum(v == "B" for v in gm.marks.values()),
"grooves": len(grooves), "plain_rakings": rp["nsol"], "par": min(rp["hist"]), "at_par": rp["hist"][min(rp["hist"])],
"answer_turns": turns_of(gm, draw), "tier_pebbles": grm["tier"], "trials_pebbles": grm["trials"], "tier_grooves": grg["tier"]}
panels = [garden_svg(gg, label=f"{len(grooves)} grooves", sublabel=f"tier {grg['tier']}", seed=5),
garden_svg(gm, label=f"{len(gm.marks)} pebbles", sublabel=f"tier {grm['tier']} ({grm['trials']} lookahead steps)", seed=5),
garden_svg(gm, draw=draw, label="The only raking", sublabel=f"{facts['answer_turns']} turns = par", seed=5)]
save(hstack(panels), os.path.join(FIG, PREFIX + "pebbles.png"), os.path.join(FIG, PREFIX + "pebbles.svg"))
rows = json.load(open("/home/claude/zen/out/marks_vs_grooves.json"))
ng, nm = [len(r["grooves"]) for r in rows], [len(r["marks"]) for r in rows]
gt, mt = [r["g_tier"] for r in rows], [r["m_tier"] for r in rows]
facts["experiment"] = {"gardens": len(rows), "grooves_median": statistics.median(ng), "pebbles_median": statistics.median(nm),
"share_more_pebbles": round(sum(b > a for a, b in zip(ng, nm)) / len(rows), 3),
"grooves_lookahead_share": round(sum(t >= 3 for t in gt) / len(rows), 3), "pebbles_lookahead_share": round(sum(t >= 3 for t in mt) / len(rows), 3),
"grooves_nested_share": round(sum(t >= 4 for t in gt) / len(rows), 3), "pebbles_nested_share": round(sum(t >= 4 for t in mt) / len(rows), 3),
"grooves_median_trials": statistics.median([r["g_trials"] for r in rows if r["g_tier"] >= 3]),
"pebbles_median_trials": statistics.median([r["m_trials"] for r in rows if r["m_tier"] >= 3])}
json.dump(facts, open("/home/claude/zen/out/facts_pebbles.json", "w"), indent=1)
print(json.dumps(facts, indent=1))
src/final_figs.py — Render all figures for the design document and record every number they show.
"""final_figs.py: render all figures for the design document and record every number they show."""
import json
import os
import statistics
from zg import Garden, glyphs, DIRS, DR, DC, OPP, turns_of, check_solution
from levels import from_dict, to_dict
from render import garden_svg, hstack, save, SEAL, INK, S
from zcp import optimize
FIG = "/home/claude/zen/fig/final"
os.makedirs(FIG, exist_ok=True)
PREFIX = "2026-10-03-gwern-samon-"
L = json.load(open("/home/claude/zen/out/levels.json"))
facts = {}
def out(name):
return os.path.join(FIG, PREFIX + name + ".png")
def svgout(name):
return os.path.join(FIG, PREFIX + name + ".svg")
def draw_of(lv):
return [tuple(c) for c in lv["intended"]]
# ------------------------------------------------------------------ 1. anatomy
lv = L["first"]
g = from_dict(lv["garden"])
draw = draw_of(lv)
rake = list(reversed(draw))
gl = glyphs(g, draw)
k = int(len(rake) * 0.45)
raked_cells = rake[:k]
partial = {c: gl[c] for c in raked_cells}
foot_prefix = draw[: len(draw) - k] # cells still showing footprints (gate ... monk position)
panels = [
garden_svg(g, label="1. the garden", seed=2),
garden_svg(g, footprints=draw, monk=draw[-1], label="2. walk in", seed=2),
garden_svg(g, glyph_override=partial, footprints=foot_prefix, monk=rake[k - 1], label="3. rake back out", seed=2),
garden_svg(g, draw=draw, label=f"4. raked: {turns_of(g, draw)} turns", seed=2),
]
sv = hstack(panels, gap=14)
save(sv, out("anatomy"), svgout("anatomy"))
facts["anatomy"] = {"rows": g.rows, "gate": g.gate, "turns": turns_of(g, draw), "par": lv["par"],
"n_rakings": lv["n_rakings"], "at_par": lv["at_par"]}
# ------------------------------------------------------------------ 2. calm vs agitated
lv = L["calm"]
g = from_dict(lv["garden"])
par_draw = draw_of(lv)
r = g.solve(mode=0, cap=10**9)
hist = {int(t): n for t, n in r["hist"].items()}
total = sum(hist.values())
# median turn count over all valid rakings
acc = 0
median_t = None
for t in sorted(hist):
acc += hist[t]
if acc >= total / 2:
median_t = t
break
mid = optimize(g, objective=None, fix={"turns_eq": median_t}, time_limit=60)
mid_draw = list(reversed(mid[2]))
worst_draw = r["worst"]["draw"]
assert check_solution(g, mid_draw) and check_solution(g, worst_draw) and check_solution(g, par_draw)
panels = [
garden_svg(g, draw=par_draw, label=f"par: {turns_of(g, par_draw)} turns", seed=5),
garden_svg(g, draw=mid_draw, label=f"median: {turns_of(g, mid_draw)} turns", seed=5),
garden_svg(g, draw=worst_draw, label=f"most agitated: {turns_of(g, worst_draw)} turns", seed=5),
]
save(hstack(panels, gap=14), out("calm-agitated"), svgout("calm-agitated"))
facts["calm"] = {"rows": g.rows, "gate": g.gate, "n_rakings": total, "par": min(hist), "at_par": hist[min(hist)],
"median_turns": median_t, "max_turns": max(hist), "at_max": hist[max(hist)],
"mode_turns": max(hist, key=hist.get), "hist": hist}
# ------------------------------------------------------------------ 3. histogram (static figure; single series + one emphasised bar)
W_, H_ = 760, 330
ml, mr, mt, mb = 70, 24, 24, 58
pw, ph = W_ - ml - mr, H_ - mt - mb
ts = list(range(min(hist), max(hist) + 1))
ymax = 10000
slot = pw / len(ts)
bw = min(24, slot - 2)
el = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W_}" height="{H_}" viewBox="0 0 {W_} {H_}">',
f'<rect width="{W_}" height="{H_}" fill="#ffffff"/>']
for yv in range(0, ymax + 1, 2000):
y = mt + ph - ph * yv / ymax
el.append(f'<line x1="{ml}" y1="{y:.1f}" x2="{ml + pw}" y2="{y:.1f}" stroke="#e4e4e1" stroke-width="1"/>')
el.append(f'<text x="{ml - 8}" y="{y + 4:.1f}" font-family="Helvetica, Arial, sans-serif" font-size="12" text-anchor="end" fill="#52514e">{yv:,}</text>')
for i, t in enumerate(ts):
n = hist.get(t, 0)
x = ml + i * slot + (slot - bw) / 2
h = ph * n / ymax
y = mt + ph - h
color = SEAL if t == min(hist) else "#8e9aa6"
if h > 0:
rr = min(4, h, bw / 2)
# rounded data end, square at the baseline
el.append(f'<path d="M{x:.1f},{mt + ph:.1f} V{y + rr:.1f} Q{x:.1f},{y:.1f} {x + rr:.1f},{y:.1f} H{x + bw - rr:.1f} '
f'Q{x + bw:.1f},{y:.1f} {x + bw:.1f},{y + rr:.1f} V{mt + ph:.1f} Z" fill="{color}"/>')
if t % 5 == 0:
el.append(f'<text x="{x + bw / 2:.1f}" y="{mt + ph + 18}" font-family="Helvetica, Arial, sans-serif" font-size="12" text-anchor="middle" fill="#52514e">{t}</text>')
el.append(f'<line x1="{ml}" y1="{mt + ph}" x2="{ml + pw}" y2="{mt + ph}" stroke="#9a9a96" stroke-width="1"/>')
el.append(f'<text x="{ml + pw / 2}" y="{H_ - 12}" font-family="Helvetica, Arial, sans-serif" font-size="13" text-anchor="middle" fill="#0b0b0b">turns in the raking</text>')
el.append(f'<text x="18" y="{mt + ph / 2}" font-family="Helvetica, Arial, sans-serif" font-size="13" text-anchor="middle" fill="#0b0b0b" transform="rotate(-90 18 {mt + ph / 2})">number of valid rakings</text>')
# par annotation with leader
i0 = ts.index(min(hist))
px = ml + i0 * slot + slot / 2
el.append(f'<circle cx="{px:.1f}" cy="{mt + ph - 3:.1f}" r="4" fill="{SEAL}" stroke="#ffffff" stroke-width="2"/>')
el.append(f'<line x1="{px:.1f}" y1="{mt + ph - 9:.1f}" x2="{px:.1f}" y2="{mt + 108:.1f}" stroke="#52514e" stroke-width="1"/>')
el.append(f'<text x="{px - 4:.1f}" y="{mt + 82:.1f}" font-family="Helvetica, Arial, sans-serif" font-size="12.5" fill="#0b0b0b">par: {min(hist)} turns</text>')
el.append(f'<text x="{px - 4:.1f}" y="{mt + 98:.1f}" font-family="Helvetica, Arial, sans-serif" font-size="12.5" fill="#52514e">{hist[min(hist)]} of {total:,} rakings</text>')
im = ts.index(median_t)
mx = ml + im * slot + slot / 2
my = mt + ph - ph * hist[median_t] / ymax
el.append(f'<text x="{mx + 16:.1f}" y="{my + 4:.1f}" font-family="Helvetica, Arial, sans-serif" font-size="12.5" fill="#52514e">median: {median_t} turns</text>')
el.append("</svg>")
save("\n".join(el), out("histogram"), svgout("histogram"))
# ------------------------------------------------------------------ 4. parity
g_ok = Garden(["....."] * 5, (4, 0, "S"))
g_bad = Garden(["....."] * 5, (4, 1, "S"))
r_ok = g_ok.solve(mode=0, cap=10**9)
r_bad = g_bad.solve(mode=0, cap=10**9)
best_ok = optimize(g_ok)
panels = [garden_svg(g_ok, draw=list(reversed(best_ok[2])), checker=True, label=f"gate on a dark square: {r_ok['nsol']} rakings", seed=1),
garden_svg(g_bad, checker=True, label=f"gate on a light square: {r_bad['nsol']} rakings", seed=1)]
save(hstack(panels, gap=14), out("parity"), svgout("parity"))
facts["parity"] = {"ok_rakings": r_ok["nsol"], "bad_rakings": r_bad["nsol"]}
# ------------------------------------------------------------------ 5-8, 10-11: puzzle | solution pairs
def pair(key_or_garden, draw, name, left="the garden", right=None, seed=3, g=None):
g = g or from_dict(L[key_or_garden]["garden"])
right = right or f"its raking: {turns_of(g, draw)} turns"
sv = hstack([garden_svg(g, label=left, seed=seed), garden_svg(g, draw=draw, label=right, seed=seed)], gap=14)
save(sv, out(name), svgout(name))
return g
for key, name in [("ripple", "ripple"), ("waves", "waves"), ("grooves", "grooves")]:
lv = L[key]
g = pair(key, draw_of(lv), name,
right=(f"unique raking: {lv['intended_turns']} turns" if lv["n_rakings"] == 1 else
f"par raking: {lv['intended_turns']} turns"))
facts[key] = {k2: lv[k2] for k2 in ("n_rakings", "par", "at_par", "graceful_par", "intended_turns", "grade") if k2 in lv}
facts[key]["rows"] = g.rows
facts[key]["gate"] = g.gate
facts[key]["clues"] = len(g.clues)
# ichimatsu 9x9
gi = Garden(["".join("=" if ((r // 3) + (c // 3)) % 2 == 0 else "|" for c in range(9)) for r in range(9)], (8, 0, "S"))
ri = gi.solve(mode=0, cap=100)
pair(None, ri["sols"][0]["draw"], "ichimatsu", g=gi, right=f"one of its {ri['nsol']} rakings: {ri['sols'][0]['turns']} turns")
facts["ichimatsu"] = {"n_rakings": ri["nsol"], "turns": [s["turns"] for s in ri["sols"]], "rows": gi.rows}
# wide rake: chosen candidate
wide = json.load(open("/home/claude/zen/out/wide_pick.json"))
gw = from_dict(wide["garden"])
dw = [tuple(c) for c in wide["draw"]]
assert check_solution(gw, dw)
assert gw.solve(mode=0, cap=2)["nsol"] == 1
gw_free = Garden(gw.rows, gw.gate)
rf = gw_free.solve(mode=0, cap=10**9, nodelimit=3 * 10**9)
pair(None, dw, "widerake", g=gw, right=f"its only raking: {turns_of(gw, dw)} turns")
facts["widerake"] = {"rows": gw.rows, "gate": gw.gate, "turns": turns_of(gw, dw), "grade": wide.get("grade"),
"plain_rakings": rf["nsol"], "plain_complete": rf["complete"] and not rf["capped"],
"plain_par": min(rf["hist"]) if rf["hist"] else None}
# two gates
two = json.load(open("/home/claude/zen/out/two_pick.json"))
gt = from_dict(two["garden"])
dt = [tuple(c) for c in two["draw"]]
assert check_solution(gt, dt) and gt.solve(mode=0, cap=2)["nsol"] == 1
pair(None, dt, "twogates", g=gt, right=f"its only raking: {turns_of(gt, dt)} turns")
facts["twogates"] = {"rows": gt.rows, "gate": gt.gate, "end": gt.end, "end_side": gt.end_side,
"turns": turns_of(gt, dt), "grade": two.get("grade")}
# ------------------------------------------------------------------ 9. the wide-rake rule
def frag_glyphs(cells, ext_start, ext_end):
"""Glyphs along a fragment; ends extended straight in the given directions."""
gl = {}
for i, c in enumerate(cells):
ds = set()
if i > 0:
p = cells[i - 1]
ds.add([d for d in DIRS if (c[0] + DR[d], c[1] + DC[d]) == p][0])
else:
ds.add(ext_start)
if i + 1 < len(cells):
n = cells[i + 1]
ds.add([d for d in DIRS if (c[0] + DR[d], c[1] + DC[d]) == n][0])
else:
ds.add(ext_end)
gl[c] = "".join(ds)
return gl
def cross(ox, oy, r, c, ok):
x, y = ox + c * S + S / 2, oy + r * S + S / 2
if ok:
return f'<path d="M{x - 9:.1f},{y:.1f} L{x - 2:.1f},{y + 7:.1f} L{x + 10:.1f},{y - 8:.1f}" fill="none" stroke="{INK}" stroke-width="3" stroke-linecap="round"/>'
return (f'<path d="M{x - 8:.1f},{y - 8:.1f} L{x + 8:.1f},{y + 8:.1f} M{x + 8:.1f},{y - 8:.1f} L{x - 8:.1f},{y + 8:.1f}" '
f'stroke="{SEAL}" stroke-width="3.2" stroke-linecap="round"/>')
ga = Garden(["......"] * 5, (4, 0, "S"))
fa = [(1, 0), (1, 1), (1, 2), (1, 3), (2, 3), (2, 2), (2, 1), (2, 0)]
gb = Garden(["...."] * 5, (4, 0, "S"))
fb = [(1, 0), (1, 1), (1, 2), (1, 3), (2, 3), (2, 2), (2, 1), (2, 0)]
gc = Garden(["......", "......", "...#..", "......", "......"], (4, 0, "S"))
fc = [(1, 0), (1, 1), (1, 2), (1, 3), (1, 4), (2, 4), (3, 4), (3, 3), (3, 2), (3, 1), (3, 0)]
panels = [
garden_svg(ga, glyph_override=frag_glyphs(fa, "W", "W"), show_gate_arrow=False,
marks_svg=lambda ox, oy: cross(ox, oy, 1.5, 4.3, False), label="open sand: forbidden", seed=7, show_gate=False),
garden_svg(gb, glyph_override=frag_glyphs(fb, "W", "W"), show_gate_arrow=False,
marks_svg=lambda ox, oy: cross(ox, oy, 3.5, 2.0, True), label="against a wall: allowed", seed=7, show_gate=False),
garden_svg(gc, glyph_override=frag_glyphs(fc, "W", "W"), show_gate_arrow=False,
marks_svg=lambda ox, oy: cross(ox, oy, 2, 5, True), label="around a stone: allowed", seed=7, show_gate=False),
]
save(hstack(panels, gap=14), out("widerake-rule"), svgout("widerake-rule"))
json.dump(facts, open("/home/claude/zen/out/facts.json", "w"), indent=1, default=str)
print(json.dumps({k: {kk: vv for kk, vv in v.items() if kk not in ("hist", "rows")} for k, v in facts.items()}, indent=1, default=str))
# ------------------------------------------------------------------ thumbnail: the wide-rake garden, raked
sv = garden_svg(gw, draw=dw, seed=3, highlight_start=False)
save(sv, out("thumbnail"), svgout("thumbnail"), scale=2.5)
src/final_tables.py — Final numbers for the document's tables (run in the background; prints Markdown-ready rows).
"""Final numbers for the document's tables (run in the background; prints Markdown-ready rows)."""
import random, statistics, sys, time, json
from multiprocessing import Pool
from zg import Garden
from gen import random_layout, parity_ok
def median_from_hist(h):
tot = sum(h.values()); acc = 0
for t in sorted(h):
acc += h[t]
if acc >= tot / 2: return t
def table1():
rows = []
for (H, W) in [(3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (6, 8)]:
g = Garden(["." * W] * H, (H - 1, 0, "S"))
r = g.solve(mode=0, cap=10**12)
h = r["hist"]
gw = Garden(["." * W] * H, (H - 1, 0, "S"), nohairpin=2, braced_start=True)
rw = g.solve(mode=0, cap=1) # placeholder to keep API symmetric
rw = gw.solve(mode=0, cap=10**9)
rows.append(dict(size=f"{H}x{W}", rakings=r["nsol"], complete=r["complete"], par=min(h), at_par=h[min(h)],
median=median_from_hist(h), max=max(h), wide=rw["nsol"],
wide_turns=sorted(rw["hist"].items())))
print(rows[-1], flush=True)
json.dump(rows, open("/home/claude/zen/out/table1.json", "w"))
RULES = {"one stroke": dict(nohairpin=0, braced_start=False),
"no U-turns at all": dict(nohairpin=1, braced_start=False),
"wide rake": dict(nohairpin=2, braced_start=True)}
CAP = 100000
def job(args):
seed, k = args
rng = random.Random(seed * 1009 + k)
g = random_layout(7, 7, k, rng, gate_side="S")
if g is None: return None
out = {"parity": parity_ok(g)}
for name, kw in RULES.items():
g.nohairpin = kw["nohairpin"]; g.braced_start = kw["braced_start"]
r = g.solve(mode=0, cap=CAP, nodelimit=5 * 10**8)
out[name] = (r["nsol"], r["capped"])
return out
def table2(n):
res_all = {}
for k in [0, 2, 4, 6]:
with Pool(2) as p:
res = [r for r in p.map(job, [(s, k) for s in range(n)]) if r]
res_all[k] = res
line = f"k={k} n={len(res)} parity-ok={sum(r['parity'] for r in res)/len(res):.2f}"
for name in RULES:
counts = [r[name][0] for r in res]
solv = [c for c in counts if c > 0]
capped = sum(1 for r in res if r[name][1])
med = statistics.median(solv) if solv else 0
line += f" | {name}: solvable {len(solv)/len(res):.3f} unique {sum(c == 1 for c in counts)/len(res):.3f} median {med:g} capped {capped}"
print(line, flush=True)
json.dump({k: v for k, v in res_all.items()}, open("/home/claude/zen/out/table2.json", "w"))
if __name__ == "__main__":
if sys.argv[1] == "1": table1()
else: table2(int(sys.argv[2]))
src/garden_sampler.py — Garden-like layouts for the campaign.
"""garden_sampler.py: garden-like layouts for the campaign.
Stones come in an odd number of pieces (1, 3 or 5), each piece 1-3 cells (single, I2, I3 or L3), kept apart
(no two pieces within one cell of each other, diagonals included), placed so the layout has no left-right,
top-bottom or 180-degree symmetry (karesansui prize asymmetry, fukinsei), and covering at most ~15% of the garden.
Optional features: a ripple stone (O, with its 8-cell ring of sand), a wave band (h/v rectangle), a closed ring
(a stone whose 8 neighbours are already raked: R cells, which are obstacles to the stroke), and a side gate.
"""
from __future__ import annotations
import random
from zg import Garden
PIECES = [ # (weight, cells)
(9, [(0, 0)]),
(4, [(0, 0), (0, 1)]), (4, [(0, 0), (1, 0)]),
(1, [(0, 0), (0, 1), (0, 2)]), (1, [(0, 0), (1, 0), (2, 0)]),
(1, [(0, 0), (1, 0), (1, 1)]), (1, [(0, 0), (0, 1), (1, 0)]), (1, [(0, 0), (0, 1), (1, 1)]), (1, [(0, 1), (1, 0), (1, 1)]),
]
def _free(rows, cells, H, W, gap):
for r, c in cells:
if not (0 <= r < H and 0 <= c < W) or rows[r][c] != ".":
return False
for dr in range(-gap, gap + 1):
for dc in range(-gap, gap + 1):
a, b = r + dr, c + dc
if 0 <= a < H and 0 <= b < W and rows[a][b] in "#OR":
return False
return True
def symmetric(rows):
"""Stones (any of #, O, R) symmetric under a left-right or top-bottom mirror, or a half turn."""
H, W = len(rows), len(rows[0])
S = {(r, c) for r in range(H) for c in range(W) if rows[r][c] in "#OR"}
if not S:
return True
return any(S == {f(r, c) for r, c in S} for f in (lambda r, c: (r, W - 1 - c), lambda r, c: (H - 1 - r, c),
lambda r, c: (H - 1 - r, W - 1 - c)))
def add_ripple(rows, rng, H, W):
for _ in range(200):
r, c = rng.randint(1, H - 2), rng.randint(1, W - 2)
block = [(r + a, c + b) for a in (-1, 0, 1) for b in (-1, 0, 1)]
if all(rows[a][b] == "." for a, b in block) and _free(rows, block, H, W, 1):
rows[r][c] = "O"
return True
return False
def add_closed_ring(rows, rng, H, W):
for _ in range(200):
r, c = rng.randint(1, H - 2), rng.randint(1, W - 2)
block = [(r + a, c + b) for a in (-1, 0, 1) for b in (-1, 0, 1)]
if all(rows[a][b] == "." for a, b in block) and _free(rows, block, H, W, 1):
for a, b in block:
rows[a][b] = "R"
rows[r][c] = "#"
return True
return False
def add_band(rows, rng, H, W, sym=None):
sym = sym or rng.choice("hv")
for _ in range(200):
if sym == "h":
h, w = rng.randint(1, 2), rng.randint(3, W - 2)
else:
h, w = rng.randint(3, H - 2), rng.randint(1, 2)
r, c = rng.randrange(0, H - h + 1), rng.randrange(0, W - w + 1)
cells = [(r + a, c + b) for a in range(h) for b in range(w)]
if all(rows[a][b] == "." for a, b in cells):
for a, b in cells:
rows[a][b] = sym
return True
return False
def add_stones(rows, rng, H, W, n_pieces, max_frac=0.15, gap=1):
placed, tries = 0, 0
budget = max(1, int(H * W * max_frac)) - sum(ch in "#" for row in rows for ch in row)
weights = [w for w, _ in PIECES]
while placed < n_pieces and tries < 500:
tries += 1
cells0 = rng.choices([p for _, p in PIECES], weights)[0]
if len(cells0) > budget - (n_pieces - placed - 1):
continue
r, c = rng.randrange(H), rng.randrange(W)
cells = [(r + a, c + b) for a, b in cells0]
if not _free(rows, cells, H, W, gap):
continue
for a, b in cells:
rows[a][b] = "#"
budget -= len(cells)
placed += 1
return placed == n_pieces
def garden_layout(H, W, rng, n_pieces=None, ripple=False, band=None, closed_ring=False, gate_side="S", max_frac=0.15):
"""A garden-like layout, or None if the constraints could not be met."""
rows = [["."] * W for _ in range(H)]
if ripple and not add_ripple(rows, rng, H, W):
return None
if closed_ring and not add_closed_ring(rows, rng, H, W):
return None
if band and not add_band(rows, rng, H, W, None if band is True else band):
return None
if n_pieces is None:
n_pieces = rng.choice([1, 3, 3]) if H * W < 49 else rng.choice([1, 3, 3, 5])
extra = sum(1 for row in rows for ch in row if ch in "O#") # a ripple stone or ringed stone counts as a piece
n_pieces = max(0, n_pieces - extra)
if n_pieces and not add_stones(rows, rng, H, W, n_pieces, max_frac=max_frac):
return None
if (n_pieces + extra) % 2 == 0 or symmetric(rows):
return None
if gate_side == "S":
cand = [(H - 1, c) for c in range(W)]
elif gate_side == "N":
cand = [(0, c) for c in range(W)]
elif gate_side == "W":
cand = [(r, 0) for r in range(H)]
else:
cand = [(r, W - 1) for r in range(H)]
cand = [p for p in cand if rows[p[0]][p[1]] in ".hv"]
if not cand:
return None
gr, gc = rng.choice(cand)
return Garden(["".join(r) for r in rows], (gr, gc, gate_side))
def add_side_gate(g: Garden, rng):
"""Pick a second gate on the north, east or west wall (the stroke must begin there)."""
opts = []
for c in range(g.W):
if g.rows[0][c] == ".":
opts.append(((0, c), "N"))
for r in range(g.H - 1):
if g.rows[r][0] == ".":
opts.append(((r, 0), "W"))
if g.rows[r][g.W - 1] == ".":
opts.append(((r, g.W - 1), "E"))
if not opts:
return False
(cell, side) = rng.choice(opts)
if cell == (g.gate[0], g.gate[1]):
return False
g.end, g.end_side = cell, side
return True
def ichimatsu(nbr, nbc, b, gate_col, flip=False):
"""A whole garden of b x b blocks raked alternately across (=) and down (|)."""
rows = []
for r in range(nbr * b):
rows.append("".join(("=" if ((r // b) + (c // b) + flip) % 2 == 0 else "|") for c in range(nbc * b)))
return Garden(rows, (nbr * b - 1, gate_col, "S"))
src/gen.py — Random garden layouts for experiments and generation.
"""Random garden layouts for experiments and generation."""
import random
from zg import Garden
def random_layout(H, W, n_stones, rng, shapes=((1,1),), gate_side=None, min_gap=0):
rows = [["."] * W for _ in range(H)]
placed = 0; tries = 0
while placed < n_stones and tries < 1000:
tries += 1
h, w = rng.choice(shapes)
r, c = rng.randrange(0, H - h + 1), rng.randrange(0, W - w + 1)
cells = [(r + i, c + j) for i in range(h) for j in range(w)]
if any(rows[a][b] != "." for a, b in cells):
continue
if min_gap:
near = [(a + da, b + db) for a, b in cells for da in range(-min_gap, min_gap + 1) for db in range(-min_gap, min_gap + 1)]
if any(0 <= a < H and 0 <= b < W and rows[a][b] != "." for a, b in near):
continue
for a, b in cells:
rows[a][b] = "#"
placed += 1
side = gate_side or rng.choice("NESW")
if side == "S":
cand = [(H - 1, c) for c in range(W)]
elif side == "N":
cand = [(0, c) for c in range(W)]
elif side == "W":
cand = [(r, 0) for r in range(H)]
else:
cand = [(r, W - 1) for r in range(H)]
cand = [p for p in cand if rows[p[0]][p[1]] == "."]
if not cand:
return None
gr, gc = rng.choice(cand)
return Garden(["".join(r) for r in rows], (gr, gc, side))
def parity_ok(g):
cells = g.sand_cells()
b = sum((r + c) % 2 == 0 for r, c in cells); w = len(cells) - b
gr, gc, _ = g.gate
gb = (gr + gc) % 2 == 0
if abs(b - w) > 1: return False
if b == w + 1 and not gb: return False
if w == b + 1 and gb: return False
return True
src/grader.py — Human-style deductive solver for one-stroke raking puzzles, used to grade difficulty.
"""grader.py: human-style deductive solver for one-stroke raking puzzles, used to grade difficulty.
Edge states: 0 unknown, 1 raked (ON), -1 not raked (OFF). The gate's exit half-edge is always ON.
Technique tiers:
tier 1 (local): saturation, forcing, dead-end => rake-start, no loops, given grooves/bands,
ripple-ring counting, wide-rake hairpin exclusion, shape marks (straight / turn).
tier 2 (global): checkerboard parity of the rake-start, connectivity of unraked sand,
no premature closure of the stroke.
tier 3 (trial): assume an edge, propagate tiers 1-2, refute by contradiction (depth 1).
tier 4: nested trials (depth 2).
grade() returns the highest tier needed plus counts.
"""
from __future__ import annotations
from zg import Garden, DIRS, DR, DC, OPP
PERP = {"N": "EW", "S": "EW", "E": "NS", "W": "NS"}
class Contradiction(Exception):
pass
class Board:
def __init__(self, g: Garden):
self.g = g
self.cells = g.sand_cells()
self.idx = {c: i for i, c in enumerate(self.cells)}
self.n = len(self.cells)
self.gate = self.idx[(g.gate[0], g.gate[1])]
self.edges = [] # (u, v) with u < v
self.eid = {} # (u, v) and (v, u) -> edge id
self.inc = [[] for _ in range(self.n)] # incident edge ids
self.dir_edge = [dict() for _ in range(self.n)] # direction -> edge id
for c in self.cells:
u = self.idx[c]
for d in "ES":
nb = (c[0] + DR[d], c[1] + DC[d])
if nb in self.idx:
v = self.idx[nb]
e = len(self.edges)
self.edges.append((u, v))
self.eid[(u, v)] = self.eid[(v, u)] = e
self.inc[u].append(e)
self.inc[v].append(e)
self.dir_edge[u][d] = e
self.dir_edge[v][OPP[d]] = e
# start candidates
cand = set(range(self.n))
if self.n > 1:
cand.discard(self.gate)
if g.end is not None:
cand = {self.idx[tuple(g.end)]}
if g.braced_start:
cand = {u for u in cand if any(g.pivot(self.cells[u][0] + DR[d], self.cells[u][1] + DC[d]) for d in DIRS)}
self.marks = {self.idx[c]: t for c, t in g.marks.items()}
cand -= set(self.marks) # a marked cell has two connections, so it is never the rake start
self.cand0 = cand
# parity-derived candidates (tier 2)
black = [((r + c) % 2 == 0) for r, c in self.cells]
nb_, nw_ = sum(black), self.n - sum(black)
gate_black = black[self.gate]
if nb_ == nw_:
want = not gate_black
elif nb_ == nw_ + 1:
want = True
elif nw_ == nb_ + 1:
want = False
else:
want = None # impossible garden
self.parity_cand = {u for u in range(self.n) if want is not None and black[u] == want}
self.parity_impossible = want is None or (nb_ == nw_ + 1 and not gate_black) or (nw_ == nb_ + 1 and gate_black)
# fixed edges from bands and grooves
self.fixed = {}
for c, need in g.zone_requirements().items():
for d in need:
self._fix(c, d, 1)
for c, want_dirs in g.clues.items():
for d in DIRS:
self._fix(c, d, 1 if d in want_dirs else -1)
# ripple rings
self.rings = []
for ring in g.ring_sets():
S = {self.idx[c] for c in ring}
cross = [e for u in S for e in self.inc[u] if (self.edges[e][0] in S) != (self.edges[e][1] in S)]
internal = [e for e in range(len(self.edges)) if self.edges[e][0] in S and self.edges[e][1] in S]
self.rings.append((S, cross, internal))
# hairpin triples (e_arm_u, e_mid, e_arm_v) that are forbidden
self.hairpins = []
if g.nohairpin:
for c in self.cells:
u = self.idx[c]
for b in "ES": # mid edge u -> v in direction b
if b not in self.dir_edge[u]:
continue
em = self.dir_edge[u][b]
v = self.edges[em][1] if self.edges[em][0] == u else self.edges[em][0]
for a in PERP[b]:
# arms go in direction -a from u and v (the U's bottom faces a)
back = OPP[a]
eu = self.dir_edge[u].get(back)
ev = self.dir_edge[v].get(back)
cu, cv = self.cells[u], self.cells[v]
braced = g.pivot(cu[0] + DR[a], cu[1] + DC[a]) and g.pivot(cv[0] + DR[a], cv[1] + DC[a])
if g.nohairpin == 2 and braced:
continue
# arms may also be the gate's exit half-edge
arm_u = eu if eu is not None else ("X" if (u == self.gate and g.gate[2] == back) else None)
arm_v = ev if ev is not None else ("X" if (v == self.gate and g.gate[2] == back) else None)
if arm_u is None or arm_v is None:
continue
self.hairpins.append((arm_u, em, arm_v))
def _fix(self, c, d, val):
u = self.idx[c]
if c == (self.g.gate[0], self.g.gate[1]) and d == self.g.gate[2]:
if val == -1:
self.fixed["contradiction"] = True
return
if d not in self.dir_edge[u]:
if val == 1:
self.fixed["contradiction"] = True
return
e = self.dir_edge[u][d]
if self.fixed.get(e, val) != val:
self.fixed["contradiction"] = True
self.fixed[e] = val
class State:
def __init__(self, B: Board, use_global=True):
self.B = B
self.s = [0] * len(B.edges)
self.cand = set(B.cand0)
if use_global:
self.cand &= B.parity_cand
self.use_global = use_global
self.start = None
def copy(self):
o = State.__new__(State)
o.B, o.s, o.cand, o.use_global, o.start = self.B, list(self.s), set(self.cand), self.use_global, self.start
return o
def set(self, e, val):
if self.s[e] == val:
return False
if self.s[e] != 0:
raise Contradiction
self.s[e] = val
return True
def ondeg(self, u):
d = sum(1 for e in self.B.inc[u] if self.s[e] == 1)
return d + (1 if u == self.B.gate else 0)
def unk(self, u):
return [e for e in self.B.inc[u] if self.s[e] == 0]
def hp_val(self, x):
return 1 if x == "X" else self.s[x]
def dstate(self, u, d):
"""State of cell u's side d: the gate's exit is ON; a side with no edge (wall, stone) is OFF."""
if u == self.B.gate and d == self.B.g.gate[2]:
return 1
e = self.B.dir_edge[u].get(d)
return -1 if e is None else self.s[e]
def dset(self, u, d, val):
if u == self.B.gate and d == self.B.g.gate[2]:
if val != 1:
raise Contradiction
return False
e = self.B.dir_edge[u].get(d)
if e is None:
if val == 1:
raise Contradiction
return False
return self.set(e, val)
def propagate(self):
B = self.B
if B.fixed.get("contradiction"):
raise Contradiction
if self.use_global and B.parity_impossible:
raise Contradiction
for e, v in B.fixed.items():
if e != "contradiction":
self.set(e, v)
changed = True
while changed:
changed = False
if not self.cand:
raise Contradiction
if len(self.cand) == 1:
self.start = next(iter(self.cand))
# cell rules
for u in range(B.n):
on, un = self.ondeg(u), self.unk(u)
is_start = self.start == u
maxdeg = 1 if is_start else 2
if on > maxdeg:
raise Contradiction
if on == maxdeg and un:
for e in un:
changed |= self.set(e, -1)
continue
if on + len(un) == 0:
raise Contradiction
if on + len(un) == 1:
# dead end: must be the rake start
if u not in self.cand:
raise Contradiction
if self.start != u:
self.cand = {u}
self.start = u
changed = True
for e in un:
changed |= self.set(e, 1)
continue
if u not in self.cand and on + len(un) == 2:
for e in un:
changed |= self.set(e, 1)
if is_start and on + len(un) >= 1 and on == 0 and len(un) == 1:
changed |= self.set(un[0], 1)
# shape marks: 'S' = both sides of one axis ON (the other OFF); 'B' = exactly one side of each axis ON
for u, t in B.marks.items():
st = {d: self.dstate(u, d) for d in DIRS}
if t == "S":
for a, b, oa, ob in (("N", "S", "E", "W"), ("E", "W", "N", "S")):
if st[a] == 1 or st[b] == 1:
for d, v in ((a, 1), (b, 1), (oa, -1), (ob, -1)):
changed |= self.dset(u, d, v)
break
if st[a] == -1 or st[b] == -1:
for d, v in ((a, -1), (b, -1), (oa, 1), (ob, 1)):
changed |= self.dset(u, d, v)
break
else:
for a, b in (("N", "S"), ("E", "W")):
if st[a] == st[b] != 0:
raise Contradiction
if st[a] != 0:
changed |= self.dset(u, b, -st[a])
elif st[b] != 0:
changed |= self.dset(u, a, -st[b])
# fragments (union-find over ON edges): forbid loops, premature closure
parent = list(range(B.n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for e, (u, v) in enumerate(B.edges):
if self.s[e] == 1:
ru, rv = find(u), find(v)
if ru == rv:
raise Contradiction
parent[ru] = rv
size = {}
for u in range(B.n):
size[find(u)] = size.get(find(u), 0) + 1
for e, (u, v) in enumerate(B.edges):
if self.s[e] != 0:
continue
ru, rv = find(u), find(v)
if ru == rv:
changed |= self.set(e, -1)
continue
if self.use_global and self.start is not None:
terminals = {find(B.gate), find(self.start)}
if {ru, rv} == terminals and size[ru] + size[rv] < B.n:
changed |= self.set(e, -1)
# hairpins
for (a, m, b) in B.hairpins:
vals = [self.hp_val(a), self.hp_val(m), self.hp_val(b)]
if vals.count(1) == 3:
raise Contradiction
if vals.count(1) == 2 and 0 in vals:
x = (a, m, b)[vals.index(0)]
if x != "X":
changed |= self.set(x, -1)
# rings
for (S, cross, internal) in B.rings:
gate_in = B.gate in S
p_true = self.start is not None and self.start in S
p_poss = bool(self.cand & S)
maxT = 2 - gate_in - (1 if p_true else 0)
minT = 2 - gate_in - (1 if p_poss else 0)
on = sum(1 for e in cross if self.s[e] == 1)
un = [e for e in cross if self.s[e] == 0]
if on > maxT or on + len(un) < minT:
raise Contradiction
if on == maxT:
for e in un:
changed |= self.set(e, -1)
elif minT == maxT and on + len(un) == minT:
for e in un:
changed |= self.set(e, 1)
ion = sum(1 for e in internal if self.s[e] == 1)
ioff = [e for e in internal if self.s[e] == -1]
iun = [e for e in internal if self.s[e] == 0]
need = len(S) - 1
if ion > need or ion + len(iun) < need:
raise Contradiction
if ion == need:
for e in iun:
changed |= self.set(e, -1)
elif ion + len(iun) == need:
for e in iun:
changed |= self.set(e, 1)
# connectivity of the sand through non-OFF edges
if self.use_global:
seen = {B.gate}
stack = [B.gate]
while stack:
u = stack.pop()
for e in B.inc[u]:
if self.s[e] != -1:
a, b = B.edges[e]
w = b if a == u else a
if w not in seen:
seen.add(w)
stack.append(w)
if len(seen) != B.n:
raise Contradiction
return self
def solved(self):
return all(x != 0 for x in self.s)
def unknown_edges(self):
return [e for e, x in enumerate(self.s) if x == 0]
def valid_final(self):
B = self.B
degs = [self.ondeg(u) for u in range(B.n)]
ones = [u for u in range(B.n) if degs[u] == 1]
if len(ones) != 1 or any(d not in (1, 2) for d in degs):
return False
# the path from the gate must cover everything
seen = {B.gate}
stack = [B.gate]
while stack:
u = stack.pop()
for e in B.inc[u]:
if self.s[e] == 1:
a, b = B.edges[e]
w = b if a == u else a
if w not in seen:
seen.add(w)
stack.append(w)
return len(seen) == B.n
def try_assume(st: State, e, val, depth):
t = st.copy()
try:
t.set(e, val)
t.propagate()
if depth > 0:
trial_loop(t, depth - 1, stats=None)
if t.solved() and not t.valid_final():
raise Contradiction
except Contradiction:
return False
return True
def trial_loop(st: State, depth, stats):
"""Repeatedly refute edge assumptions at the given depth; mutates st."""
progress = True
while progress and not st.solved():
progress = False
for e in st.unknown_edges():
if st.s[e] != 0:
continue
for val in (1, -1):
if not try_assume(st, e, val, depth):
st.set(e, -val)
st.propagate()
if stats is not None:
stats["trials"] += 1
progress = True
break
return st
def grade(g: Garden, max_depth=2):
"""Return dict(tier=1..5, trials, solved). tier 5 = not solved by depth<=max_depth trials."""
B = Board(g)
# tier 1: local rules only
st = State(B, use_global=False)
try:
st.propagate()
except Contradiction:
return {"tier": 0, "trials": 0, "solved": False, "note": "contradiction (unsolvable)"}
if st.solved() and st.valid_final():
return {"tier": 1, "trials": 0, "solved": True}
st = State(B, use_global=True)
try:
st.propagate()
except Contradiction:
return {"tier": 0, "trials": 0, "solved": False, "note": "contradiction (unsolvable)"}
if st.solved() and st.valid_final():
return {"tier": 2, "trials": 0, "solved": True}
for depth in range(max_depth):
stats = {"trials": 0}
t = st.copy()
try:
trial_loop(t, depth, stats)
except Contradiction:
return {"tier": 0, "trials": stats["trials"], "solved": False, "note": "contradiction in trials"}
if t.solved() and t.valid_final():
return {"tier": 3 + depth, "trials": stats["trials"], "solved": True}
st = t
return {"tier": 3 + max_depth, "trials": None, "solved": False}
src/hints.py — The abbot's first observation for generated gardens, derived from solver facts (so true by construction).
"""hints.py: the abbot's first observation for generated gardens, derived from solver facts (so true by construction).
The observation is chosen as the one fact that most changes the player's plan:
free gardens: among facts true of every par raking (where it ends, how it begins, a whole row or column raked in one
line, whether the line turns or runs straight in some cell), the one that the most near-misses (rakings
one or two turns over par) get wrong;
strict gardens: among facts about the unique raking (where the rake is set down; whether the line turns or runs straight
in some cell), the one that lets the grader's local and global rules (tiers 1-2) decide the most of
the garden, i.e. the step a player would otherwise need lookahead for.
Each observation comes with the cells the abbot points at. Parity lessons: the colour count, and why the far corner fails.
trap_score() measures how strongly a garden punishes the obvious move (see there).
"""
from __future__ import annotations
import copy
from collections import deque
ORD = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth"]
WORD = {"N": "straight up", "E": "right", "W": "left", "S": "down"}
def _dir(a, b):
return {(-1, 0): "N", (1, 0): "S", (0, 1): "E", (0, -1): "W"}[(b[0] - a[0], b[1] - a[1])]
def row_phrase(g, r):
if r == 0:
return "in the top row"
if r == g.H - 1:
return "in the bottom row"
return f"in the {ORD[r]} row from the top" if r < g.H / 2 else f"in the {ORD[g.H - 1 - r]} row from the bottom"
def col_phrase(g, c):
if c == 0:
return "against the left wall"
if c == g.W - 1:
return "against the right wall"
return f"{ORD[c]} from the left" if c < g.W / 2 else f"{ORD[g.W - 1 - c]} from the right"
def gate_word(g):
return "veranda gate" if g.end is not None else "gate" # with a side gate, "the gate" is ambiguous
def describe(g, cell):
r, c = cell
gr, gc = g.gate[0], g.gate[1]
if (r, c) == (gr, gc):
return f"in the cell just inside the {gate_word(g)}"
if r == gr and abs(c - gc) == 1:
return f"right beside the {gate_word(g)}"
corners = {(0, 0): "in the top-left corner", (0, g.W - 1): "in the top-right corner",
(g.H - 1, 0): "in the bottom-left corner", (g.H - 1, g.W - 1): "in the bottom-right corner"}
if (r, c) in corners:
return corners[(r, c)]
return f"{row_phrase(g, r)}, {col_phrase(g, c)}"
def cell_noun(g, cell):
"""The cell as a noun phrase: 'the top-left corner', 'the cell just inside the gate', 'the cell in the top row, ...'."""
d = describe(g, cell)
if d.startswith("in the cell"):
return d[len("in "):]
if d.startswith("in the ") and d.endswith(" corner"):
return d[len("in "):]
return "the cell " + d
def one_way_in(g, cell):
r, c = cell
return (r, c) != (g.gate[0], g.gate[1]) and sum(g.is_sand(r + a, c + b) for a, b in ((-1, 0), (1, 0), (0, -1), (0, 1))) == 1
def features(g):
s = "".join(g.rows)
return {"wide": g.nohairpin == 2, "side": g.end is not None, "pebbles": bool(g.marks), "grooves": bool(g.clues),
"ripple": "O" in s, "band": ("h" in s or "v" in s), "blocks": ("=" in s or "|" in s), "ring": "R" in s}
def line_name(g, kind, i):
if kind == "row":
return "top row" if i == 0 else "bottom row" if i == g.H - 1 else (
f"{ORD[i]} row from the top" if i < g.H / 2 else f"{ORD[g.H - 1 - i]} row from the bottom")
return "left-hand column" if i == 0 else "right-hand column" if i == g.W - 1 else (
f"{ORD[i]} column from the left" if i < g.W / 2 else f"{ORD[g.W - 1 - i]} column from the right")
def common_line(g, par_rakings):
"""A whole row or column of plain sand (wall to wall) that every par raking rakes as one straight line."""
lines = [("row", r, [(r, c) for c in range(g.W)]) for r in range(g.H)] + \
[("column", c, [(r, c) for r in range(g.H)]) for c in range(g.W)]
best = None
for kind, i, cells in lines:
if not all(g.rows[x[0]][x[1]] == "." for x in cells) or len(cells) < 4: # plain sand: a band row is straight anyway
continue
fwd, back = [tuple(x) for x in cells], [tuple(x) for x in reversed(cells)]
n = len(cells)
def has(d):
d = [tuple(x) for x in d]
return any(d[k:k + n] in (fwd, back) for k in range(len(d) - n + 1))
if all(has(d) for d in par_rakings) and (best is None or n > best[0]):
best = (n, kind, i)
if best is None:
return None
n, kind, i = best
return f"Every par raking rakes the whole {line_name(g, kind, i)} in one straight line."
def hint_free(g, par, par_rakings):
ends = {tuple(d[-1]) for d in par_rakings}
firsts = {_dir(d[0], d[1]) for d in par_rakings if len(d) > 1}
gate = (g.gate[0], g.gate[1])
f = features(g)
rings = "".join(g.rows).count("O")
ring_txt = "Rake the ring in one run" if rings == 1 else "Rake each ring in one run"
lead = (f"{ring_txt}, and cross each band in one straight run. " if f["ripple"] and f["band"] else
f"{ring_txt}. " if f["ripple"] else "Cross each band in one straight run. " if f["band"] else "")
if len(ends) == 1:
return lead + f"At par, the walk ends {describe(g, next(iter(ends)))}."
if all(abs(e[0] - gate[0]) + abs(e[1] - gate[1]) == 1 for e in ends):
return lead + "At par, the walk ends right beside the gate."
rows, cols = {e[0] for e in ends}, {e[1] for e in ends}
if len(rows) == 1:
return lead + f"At par, the walk ends {row_phrase(g, next(iter(rows)))}."
if len(cols) == 1 and next(iter(cols)) in (0, g.W - 1):
return lead + f"At par, the walk ends {col_phrase(g, next(iter(cols)))}."
if len(firsts) == 1:
return lead + f"Every par raking begins by walking {WORD[next(iter(firsts))]} from the gate."
line = common_line(g, par_rakings)
if line:
return lead + line
return lead + f"Par is {par} turns: long lines from wall to wall, and as few U-turns as the stones allow."
def hint_strict(g, draw, all_draws=None):
f = features(g)
draws = all_draws or [draw]
ends = {tuple(d[-1]) for d in draws}
end_txt = f"the rake is set down {describe(g, next(iter(ends)))}" if len(ends) == 1 else None
if f["side"]:
heads = {_dir(d[0], d[1]) for d in draws if len(d) > 1}
if len(heads) == 1:
first = f"From the veranda gate, the walk first heads {WORD[next(iter(heads))]}."
else:
first = "Plan from both gates at once."
tail = " Every reversal needs a pivot." if f["wide"] else " Settle the pebbles beside walls and stones first." if f["pebbles"] else ""
return first + tail
if f["blocks"]:
lead = "Rake each block as parallel lines, then step into the next."
elif f["pebbles"]:
lead = "Settle the pebbles beside walls and stones first: there, often only one way through fits."
elif f["grooves"]:
lead = "Extend each of the abbot's grooves into its neighbours first."
elif f["wide"]:
lead = "Every reversal needs a pivot: a wall or a stone."
else:
lead = "Long lines first."
if f["ring"] and not f["pebbles"]:
lead += " Leave the finished ring as it is."
return lead + (f" Here {end_txt}." if end_txt else "")
def far_cell(g):
start = (g.gate[0], g.gate[1])
dist, q = {start: 0}, deque([start])
while q:
a = q.popleft()
for d in ((1, 0), (-1, 0), (0, 1), (0, -1)):
b = (a[0] + d[0], a[1] + d[1])
if g.is_sand(*b) and b not in dist:
dist[b] = dist[a] + 1
q.append(b)
H, W = g.H, g.W
return max(dist, key=lambda c: (dist[c], c in ((0, 0), (0, W - 1), (H - 1, 0), (H - 1, W - 1))))
def hint_parity(g, all_draws):
cells = g.sand_cells()
dark = sum((r + c) % 2 == 0 for r, c in cells)
light = len(cells) - dark
gate_dark = (g.gate[0] + g.gate[1]) % 2 == 0
if dark == light + 1:
rule, end_dark = "There is one more dark cell than light, so the walk ends on a dark cell", True
elif light == dark + 1:
rule, end_dark = "There is one more light cell than dark, so the walk ends on a light cell", False
else:
end_dark = not gate_dark
rule = f"Dark and light cells are equal in number and the gate is on a {'dark' if gate_dark else 'light'} cell, so the walk ends on a {'dark' if end_dark else 'light'} one"
far = far_cell(g)
far_dark = (far[0] + far[1]) % 2 == 0
assert all(((d[-1][0] + d[-1][1]) % 2 == 0) == end_dark for d in all_draws), "parity rule contradicted by a raking"
if far_dark != end_dark:
assert all(tuple(d[-1]) != far for d in all_draws)
return f"{rule}: not {describe(g, far)}, which is {'dark' if far_dark else 'light'}."
return rule + "."
# ---------------------------------------------------------------- the observation that changes the plan
STRAIGHT_IN = {"S": "N", "N": "S", "E": "W", "W": "E"} # the obvious first move: straight in through the gate
ZONE = set("hv=|")
def first_dir(d):
return _dir(d[0], d[1]) if len(d) > 1 else None
def shapes(g, d):
"""Cell -> 'S' (the line runs straight through it) or 'B' (it turns there), along a walk from the gate; the last cell
(where the rake is set down) has no shape."""
d = [tuple(c) for c in d]
out = {}
for i, c in enumerate(d[:-1]):
din = STRAIGHT_IN[g.gate[2]] if i == 0 else _dir(d[i - 1], c)
out[c] = "S" if _dir(c, d[i + 1]) == din else "B"
return out
def near_misses(g, par, slack=2, cap=300000):
"""Rakings within `slack` turns over par (the plans a player is most likely to find instead of par)."""
from zg import turns_of
r = g.solve(mode=2, bound=par + slack, cap=cap, stream=True)
return [s_["draw"] for s_ in r["sols"] if turns_of(g, s_["draw"]) > par]
def _beside_gate(g):
gr, gc, side = g.gate
return {(gr, gc - 1), (gr, gc + 1)} if side in "NS" else {(gr - 1, gc), (gr + 1, gc)}
def free_facts(g, par_rakings):
"""Facts true of every par raking: (kind rank, text, cells pointed at, test on a raking)."""
P = [[tuple(c) for c in d] for d in par_rakings]
gate = (g.gate[0], g.gate[1])
F = []
ends = {d[-1] for d in P}
if len(ends) == 1:
e = next(iter(ends))
F.append((0, f"At par, the walk ends {describe(g, e)}.", [e], lambda d, e=e: tuple(d[-1]) == e))
else:
bg = _beside_gate(g)
if ends <= bg:
F.append((0, "At par, the walk ends right beside the gate.", sorted(ends), lambda d, bg=bg: tuple(d[-1]) in bg))
rows, cols = {e[0] for e in ends}, {e[1] for e in ends}
if len(rows) == 1:
r = next(iter(rows))
F.append((1, f"At par, the walk ends in the {line_name(g, 'row', r)}.", sorted(ends), lambda d, r=r: d[-1][0] == r))
if len(cols) == 1:
c = next(iter(cols))
F.append((1, f"At par, the walk ends in the {line_name(g, 'column', c)}.", sorted(ends), lambda d, c=c: d[-1][1] == c))
firsts = {first_dir(d) for d in P}
if len(firsts) == 1:
f = next(iter(firsts))
nxt = P[0][1]
F.append((0, f"Every par raking begins by walking {WORD[f]} from the gate.", [gate, nxt], lambda d, f=f: first_dir(d) == f))
for kind, i, cells in [("row", r, [(r, c) for c in range(g.W)]) for r in range(g.H)] + \
[("column", c, [(r, c) for r in range(g.H)]) for c in range(g.W)]:
if len(cells) < 4 or not all(g.rows[x[0]][x[1]] == "." for x in cells):
continue
fwd, back, n = list(cells), list(reversed(cells)), len(cells)
def has(d, fwd=fwd, back=back, n=n):
d = [tuple(x) for x in d]
return any(d[k:k + n] in (fwd, back) for k in range(len(d) - n + 1))
if all(has(d) for d in P):
F.append((1, f"Every par raking rakes the whole {line_name(g, kind, i)} in one straight line.", cells, has))
# a stretch of a row or column between stones or walls (not the whole line), raked in one straight run at par
for kind, i, cells in [("row", r, [(r, c) for c in range(g.W)]) for r in range(g.H)] + \
[("column", c, [(r, c) for r in range(g.H)]) for c in range(g.W)]:
runs, cur = [], []
for x in cells:
if g.rows[x[0]][x[1]] == ".":
cur.append(x)
else:
runs.append(cur); cur = []
runs.append(cur)
for seg in runs:
if len(seg) < 3 or len(seg) == len(cells):
continue
fwd, back, n = list(seg), list(reversed(seg)), len(seg)
def has(d, fwd=fwd, back=back, n=n):
d = [tuple(x) for x in d]
return any(d[k:k + n] in (fwd, back) for k in range(len(d) - n + 1))
if all(has(d) for d in P):
F.append((1, f"Every par raking rakes the marked stretch of the {line_name(g, kind, i)} in one straight line.", seg, has))
S = [shapes(g, d) for d in P]
for c in sorted(set.intersection(*[set(x) for x in S])):
if g.rows[c[0]][c[1]] in ZONE or c in g.marks or c in g.clues:
continue
t = {x[c] for x in S}
if len(t) == 1:
t = next(iter(t))
text = (f"At par, the line turns at {cell_noun(g, c)}." if t == "B"
else f"At par, the line runs straight through {cell_noun(g, c)}.")
F.append((2, text, [c], lambda d, c=c, t=t: shapes(g, d).get(c) == t))
return F
def best_free_hint(g, par, par_rakings, near=None):
"""The fact true of every par raking that the most near-misses get wrong (ties: the more natural kind of fact)."""
F = free_facts(g, par_rakings)
if not F:
return f"Par is {par} turns: long lines from wall to wall, and as few U-turns as the stones allow.", [], None
near = near_misses(g, par) if near is None else near
scored = []
for rank, text, cells, test in F:
assert all(test(d) for d in par_rakings), text # true of every par raking, by construction
miss = sum(not test(d) for d in near) / len(near) if near else 0.0
scored.append((miss - 0.04 * rank, -rank, text, cells, miss))
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
_, _, text, cells, miss = scored[0]
return text, [list(c) for c in cells], round(miss, 3)
def _decided(B, end=None, mark=None):
"""How much of the garden the grader's tier 1-2 rules decide, given one more fact; and whether that solves it."""
from grader import State, Contradiction
B2 = copy.copy(B)
if end is not None:
B2.cand0 = {B.idx[end]}
if mark is not None:
c, t = mark
B2.marks = dict(B.marks)
B2.marks[B.idx[c]] = t
B2.cand0 = set(B.cand0) - {B.idx[c]}
st = State(B2, use_global=True)
try:
st.propagate()
except Contradiction:
raise AssertionError("a true fact led to a contradiction")
return sum(1 for x in st.s if x != 0), st.solved() and st.valid_final()
def with_fact(g, cells, text):
"""The garden with a strict hint's fact added as a given: the end cell fixed, or a pebble at the cell."""
g2 = copy.deepcopy(g)
c = tuple(cells[0])
if "rake is set down" in text:
g2.end = c
else:
g2.marks = dict(g.marks); g2.marks[c] = "B" if "turns" in text else "S"
return g2
def best_strict_hint(g, draw, regrade=8):
"""The fact about the unique raking that most lowers the garden's grade (tier, then lookahead steps), among the
end cell and the `regrade` facts that let the tier 1-2 rules decide the most; ties: more decided, then where the
rake is set down, then the cell earliest in the walk."""
from grader import Board, grade
B = Board(g)
d = [tuple(c) for c in draw]
base, _ = _decided(B)
cands = []
if g.end is None:
e = d[-1]
n, solved = _decided(B, end=e)
# say why, when the reason is short: a cell with one way in is where the walk must end
noun = cell_noun(g, e)
noun = noun[0].upper() + noun[1:] + ("," if "," in noun else "")
text = (f"{noun} has only one way in, so the rake is set down there." if one_way_in(g, e)
else f"The rake is set down {describe(g, e)}.")
cands.append([solved, n, 1, 0, text, [e]])
sh = shapes(g, d)
for i, c in enumerate(d[:-1]):
if g.rows[c[0]][c[1]] in ZONE or c in g.marks or c in g.clues:
continue
n, solved = _decided(B, mark=(c, sh[c]))
text = (f"The line turns at {cell_noun(g, c)}." if sh[c] == "B"
else f"The line runs straight through {cell_noun(g, c)}.")
cands.append([solved, n, 0, -i, text, [c]])
if not cands:
return "Follow the hatching: every block is raked in parallel lines.", [], (base, base)
cands.sort(key=lambda x: x[:4], reverse=True)
short = cands[:regrade] + [c for c in cands[regrade:] if c[2] == 1]
best = None
for c in short:
gr = grade(with_fact(g, c[5], c[4]))
assert gr["solved"], c[4]
key = (gr["tier"], gr["trials"] or 0, -c[1], -c[2], -c[3])
if best is None or key < best[0]:
best = (key, c)
_, (solved, n, _, _, text, cells) = best
return text, [list(c) for c in cells], (base, n)
def trap_score(g, par, par_rakings=None, near=None, draw=None):
"""How strongly the garden punishes the obvious move (0 = not at all).
Free: the share of near-misses (rakings one or two turns over par) that get the garden's key observation wrong (the
best_free_hint fact), counted only when it is most of them: the plan that comes naturally is wrong there.
(The first move is no use here: in almost every free garden, the near-misses' favourite first move is also a par move.)
Strict: 1 when the only raking turns aside at once, but walking straight in through the gate is not refuted by the
tier 1-2 rules (it takes lookahead to see why it fails)."""
if draw is None:
if not near:
return 0.0
_, _, miss = best_free_hint(g, par, par_rakings, near)
return miss if miss >= 0.5 else 0.0
obvious = STRAIGHT_IN[g.gate[2]]
gr, gc = g.gate[0], g.gate[1]
nxt = {"N": (gr - 1, gc), "S": (gr + 1, gc), "E": (gr, gc + 1), "W": (gr, gc - 1)}[obvious]
if not g.is_sand(*nxt) or first_dir(draw) == obvious:
return 0.0
from grader import Board, State, Contradiction
B = Board(g)
st = State(B, use_global=True)
st.propagate()
e = B.dir_edge[B.idx[(gr, gc)]].get(obvious)
if e is None:
return 0.0
try:
t = st.copy(); t.set(e, 1); t.propagate()
except Contradiction:
return 0.0
return 1.0
src/levels.py — Search for example gardens per mechanic, verify them, and save to JSON.
"""levels.py: search for example gardens per mechanic, verify them, and save to JSON.
Every saved level records: layout, rules, the number of valid rakings (exact, or a lower bound if capped),
par (proven by CP-SAT), whether par is achievable 'gracefully' (no hairpin turn in open sand),
the intended raking, and the deductive grade."""
from __future__ import annotations
import json
import random
import sys
import time
from copy import deepcopy
from zg import Garden, glyphs, hairpin_list, turns_of, check_solution
from zcp import optimize
from gen import random_layout, parity_ok
from clues import make_unique, with_clues
from grader import grade
OUT = "/home/claude/zen/out/levels.json"
def to_dict(g: Garden):
return {"rows": g.rows, "gate": list(g.gate), "clues": {f"{r},{c}": v for (r, c), v in g.clues.items()},
"end": list(g.end) if g.end else None, "nohairpin": g.nohairpin, "braced_start": g.braced_start,
"name": g.name, "end_side": g.end_side, "marks": {f"{r},{c}": t for (r, c), t in g.marks.items()}}
def from_dict(d) -> Garden:
g = Garden(d["rows"], tuple(d["gate"]), clues={tuple(map(int, k.split(","))): v for k, v in d["clues"].items()},
end=tuple(d["end"]) if d["end"] else None, nohairpin=d["nohairpin"], braced_start=d["braced_start"],
name=d.get("name", ""), end_side=d.get("end_side"),
marks={tuple(map(int, k.split(","))): v for k, v in d.get("marks", {}).items()})
return g
def par_info(g: Garden, time_limit=120):
free = optimize(g, time_limit=time_limit)
if free[1] != "OPTIMAL":
return None
grace = optimize(g, nohairpin=2, time_limit=time_limit)
return {"par": free[0], "par_draw": list(reversed(free[2])),
"graceful_par": grace[0] if grace[1] == "OPTIMAL" else None,
"graceful_draw": list(reversed(grace[2])) if grace[2] else None}
def summarize(g: Garden, intended_draw=None, cap=10**7):
r = g.solve(mode=0, cap=cap, nodelimit=2 * 10**9)
info = {"n_rakings": r["nsol"], "count_exact": bool(r["complete"] and not r["capped"]),
"hist": r["hist"]}
p = par_info(g)
if p:
info.update({k: v for k, v in p.items() if not k.endswith("draw")})
info["at_par"] = r["hist"].get(p["par"], 0) if info["count_exact"] else None
if intended_draw is None and p:
intended_draw = p["graceful_draw"] if p["graceful_par"] == p["par"] else p["par_draw"]
if intended_draw is not None:
assert check_solution(g, intended_draw), "intended raking fails the rules"
info["intended"] = [list(c) for c in intended_draw]
info["intended_turns"] = turns_of(g, intended_draw)
info["intended_open_hairpins"] = sum(1 for *_, br in hairpin_list(g, intended_draw) if not br)
info["grade"] = grade(g) if r["nsol"] == 1 else None
return info
def graceful(g, info):
return info.get("graceful_par") is not None and info["graceful_par"] == info["par"]
# ---------------------------------------------------------------- searches
def search_first_stroke(rng, budget=60):
t0 = time.time()
best = None
while time.time() - t0 < budget:
g = random_layout(6, 6, 2, rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0:
continue
p = par_info(g, 30)
if not p or p["graceful_par"] != p["par"]:
continue
g.name = "First stroke"
return g
return best
def search_ripple(rng, budget=120):
t0 = time.time()
while time.time() - t0 < budget:
H, W = 7, 7
rows = [["."] * W for _ in range(H)]
r, c = rng.randint(2, H - 3), rng.randint(2, W - 3)
rows[r][c] = "O"
for _ in range(rng.randint(1, 2)):
a, b = rng.randrange(H), rng.randrange(W)
if rows[a][b] == "." and max(abs(a - r), abs(b - c)) > 2:
rows[a][b] = "#"
gc = rng.choice([x for x in range(W) if rows[H - 1][x] == "."])
g = Garden(["".join(x) for x in rows], (H - 1, gc, "S"), name="Ripples")
if not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0:
continue
p = par_info(g, 30)
if p and p["graceful_par"] == p["par"]:
return g
return None
def search_waves(rng, budget=120):
t0 = time.time()
while time.time() - t0 < budget:
H, W = 7, 8
rows = [["."] * W for _ in range(H)]
r0 = rng.randint(1, 2)
for r in range(r0, r0 + 2):
for c in range(1, W - 1):
rows[r][c] = "h"
for _ in range(rng.randint(1, 2)):
a, b = rng.randint(r0 + 3, H - 2), rng.randrange(1, W - 1)
rows[a][b] = "#"
gc = rng.choice([x for x in range(W) if rows[H - 1][x] == "."])
g = Garden(["".join(x) for x in rows], (H - 1, gc, "S"), name="Waves")
if not parity_ok(g) or g.solve(mode=0, cap=1)["nsol"] == 0:
continue
p = par_info(g, 30)
if p and p["graceful_par"] == p["par"]:
return g
return None
def ichimatsu_garden():
rows = ["===|||", "===|||", "===|||", "|||===", "|||===", "|||==="]
return Garden(rows, (5, 0, "S"), name="Ichimatsu")
def search_grooves(rng, budget=300, size=(7, 7), stones=(2, 3), min_tier=3, max_clues=6, name="Master's grooves", extra=None):
t0 = time.time()
cands = []
while time.time() - t0 < budget:
g = random_layout(size[0], size[1], rng.choice(stones), rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g):
continue
if extra:
extra(g, rng)
if g.solve(mode=0, cap=1)["nsol"] == 0:
continue
p = par_info(g, 30)
if not p or p["graceful_par"] != p["par"]:
continue
target = p["graceful_draw"]
clues = make_unique(g, target, rng)
if clues is None or len(clues) > max_clues:
continue
gu = with_clues(g, clues)
gr = grade(gu)
if gr["solved"] and gr["tier"] >= min_tier:
gu.name = name
return gu, target, gr
cands.append((gr["tier"], gu, target, gr))
if cands:
cands.sort(key=lambda x: -x[0])
_, gu, target, gr = cands[0]
gu.name = name
return gu, target, gr
return None
def search_wide(rng, budget=300, size=(7, 7)):
t0 = time.time()
best = None
while time.time() - t0 < budget:
g = random_layout(size[0], size[1], rng.choice([2, 3]), rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g):
continue
g.nohairpin = 2
g.braced_start = True
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1:
continue
gr = grade(g)
if not gr["solved"]:
continue
key = (gr["tier"], gr["trials"] or 0)
if best is None or key > best[0]:
g.name = "Wide rake"
best = (key, g, r["sols"][0]["draw"], gr)
if gr["tier"] >= 3 and (gr["trials"] or 0) >= 8:
break
return best
def search_two_gates(rng, budget=300):
t0 = time.time()
while time.time() - t0 < budget:
g = random_layout(7, 7, rng.choice([2, 3]), rng, gate_side="S", min_gap=1)
if g is None:
continue
# side gate on the east wall: the rake must start there
er = rng.randint(0, 4)
if g.rows[er][6] != ".":
continue
g.end = (er, 6)
r = g.solve(mode=0, cap=3)
if r["nsol"] != 1:
continue
gr = grade(g)
if gr["solved"] and gr["tier"] >= 2:
g.name = "Two gates"
return g, r["sols"][0]["draw"], gr
return None
def main():
seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1
rng = random.Random(seed)
levels = {}
g1 = Garden(["........", "........", "..#.....", "........", ".....#..", "........", "........"], (6, 3, "S"),
name="Calm and agitated")
levels["calm"] = {"garden": to_dict(g1), **summarize(g1)}
print("calm", {k: v for k, v in levels["calm"].items() if k not in ("garden", "intended", "hist")}, flush=True)
g = search_first_stroke(rng)
levels["first"] = {"garden": to_dict(g), **summarize(g)}
print("first", {k: v for k, v in levels["first"].items() if k not in ("garden", "intended", "hist")}, flush=True)
g = search_ripple(rng)
levels["ripple"] = {"garden": to_dict(g), **summarize(g)}
print("ripple", {k: v for k, v in levels["ripple"].items() if k not in ("garden", "intended", "hist")}, flush=True)
g = search_waves(rng)
levels["waves"] = {"garden": to_dict(g), **summarize(g)}
print("waves", {k: v for k, v in levels["waves"].items() if k not in ("garden", "intended", "hist")}, flush=True)
g = ichimatsu_garden()
levels["ichimatsu"] = {"garden": to_dict(g), **summarize(g)}
print("ichimatsu", {k: v for k, v in levels["ichimatsu"].items() if k not in ("garden", "intended", "hist")}, flush=True)
res = search_grooves(rng)
if res:
gu, target, gr = res
levels["grooves"] = {"garden": to_dict(gu), **summarize(gu, intended_draw=target)}
print("grooves", {k: v for k, v in levels["grooves"].items() if k not in ("garden", "intended", "hist")}, flush=True)
res = search_wide(rng)
if res:
_, g, draw, gr = res
levels["wide"] = {"garden": to_dict(g), **summarize(g, intended_draw=draw)}
print("wide", {k: v for k, v in levels["wide"].items() if k not in ("garden", "intended", "hist")}, flush=True)
res = search_two_gates(rng)
if res:
g, draw, gr = res
levels["twogates"] = {"garden": to_dict(g), **summarize(g, intended_draw=draw)}
print("twogates", {k: v for k, v in levels["twogates"].items() if k not in ("garden", "intended", "hist")}, flush=True)
with open(OUT, "w") as f:
json.dump(levels, f, indent=1)
print("saved", OUT)
if __name__ == "__main__":
main()
src/pairs.py — Contrasting pairs that introduce each rule: the same small garden twice, before and after one change.
"""pairs.py: contrasting pairs that introduce each rule: the same small garden twice, before and after one change.
Each search samples small layouts and keeps the best pair it finds in a time budget. A pair is kept only if the change
matters: the old way of raking the first garden must fail in the second (ripple stone, band, wide rake), or the
second must be pinned down to one raking by a few marks (grooves; pebbles in place of grooves), or the side gate must
change the answer. Smaller and easier pairs are preferred.
"""
from __future__ import annotations
import copy
import math
import random
import time
from zg import Garden, check_solution, hairpin_list, turns_of, glyphs
from gen import parity_ok
from garden_sampler import add_ripple, add_band, add_stones, add_closed_ring, add_side_gate, garden_layout
from clues import make_unique, with_clues, make_unique_marks, with_marks
from grader import grade
from zcp import optimize
SIZES = [(4, 5), (5, 4), (5, 5), (4, 6), (5, 6)]
def counted(g, cap=10**6):
r = g.solve(mode=0, cap=cap)
return r if (r["complete"] and not r["capped"]) else None
def par_rakings(g, par):
return [s["draw"] for s in g.solve(mode=2, bound=par, cap=10**5, stream=True)["sols"]]
def open_u_turn(g, d):
return any(not braced for *_, braced in hairpin_list(g, d))
def bottom_gate(rows, rng):
H = len(rows)
cand = [c for c in range(len(rows[0])) if rows[H - 1][c] == "."]
return (H - 1, rng.choice(cand), "S") if cand else None
def as_rows(rows):
return ["".join(r) for r in rows]
def habit_fails(A, B):
"""Every par raking of A breaks B's rule: the plan that worked before no longer does."""
ra, rb = counted(A), counted(B)
if ra is None or rb is None or ra["nsol"] < 3 or rb["nsol"] < 1:
return None
pa = min(ra["hist"])
PA = par_rakings(A, pa)
if any(check_solution(B, d) for d in PA):
return None
return ra, rb, pa
def search(make, budget, rng):
best, t0, tried = None, time.time(), 0
while time.time() - t0 < budget:
tried += 1
c = make(rng)
if c is not None and (best is None or c["score"] < best["score"]):
best = c
if best:
best["tried"] = tried
return best
# ---------------------------------------------------------------- the pairs
def graceful_par(g):
"""Some par raking has no U-turn in open sand (the generator's filter for free gardens: the seal should not reward
only meanders)."""
free, grace = optimize(g, time_limit=20), optimize(g, nohairpin=2, time_limit=20)
return free[1] == "OPTIMAL" and grace[1] == "OPTIMAL" and grace[0] == free[0]
def make_ripple(rng):
H, W = rng.choice(SIZES)
rows = [["."] * W for _ in range(H)]
if not add_ripple(rows, rng, H, W):
return None
if rng.random() < 0.5 and not add_stones(rows, rng, H, W, 1, max_frac=0.12):
return None
gate = bottom_gate(rows, rng)
if gate is None:
return None
B = Garden(as_rows(rows), gate)
A = Garden([r.replace("O", "#") for r in B.rows], gate)
if not parity_ok(B):
return None
h = habit_fails(A, B)
if h is None or h[1]["nsol"] < 2 or not (graceful_par(A) and graceful_par(B)):
return None
return {"A": A, "B": B, "score": (len(B.sand_cells()), h[1]["nsol"]), "facts": {"A_rakings": h[0]["nsol"], "B_rakings": h[1]["nsol"]}}
def make_band(rng):
H, W = rng.choice([(5, 5), (5, 6), (4, 6), (6, 5)])
rows = [["."] * W for _ in range(H)]
if rng.random() < 0.6 and not add_stones(rows, rng, H, W, 1, max_frac=0.12):
return None
plain = [r[:] for r in rows]
sym = "h" if H < 5 else "v" if W < 5 else rng.choice("hv") # (a band needs room for at least three cells)
if not add_band(rows, rng, H, W, sym):
return None
gate = bottom_gate(rows, rng)
if gate is None:
return None
A, B = Garden(as_rows(plain), gate), Garden(as_rows(rows), gate)
if not parity_ok(A):
return None
h = habit_fails(A, B)
if h is None or h[1]["nsol"] < 2 or not (graceful_par(A) and graceful_par(B)):
return None
return {"A": A, "B": B, "score": (len(B.sand_cells()), h[1]["nsol"]), "facts": {"A_rakings": h[0]["nsol"], "B_rakings": h[1]["nsol"]}}
def graceful_target(g):
free, grace = optimize(g, time_limit=20), optimize(g, nohairpin=2, time_limit=20)
if free[1] != "OPTIMAL" or grace[1] != "OPTIMAL" or grace[0] != free[0]:
return None
return list(reversed(grace[2]))
def make_grooves(rng, max_clues=3):
H, W = rng.choice([(5, 5), (5, 6), (6, 5), (6, 6)])
A = garden_layout(H, W, rng, n_pieces=1, max_frac=0.12)
if A is None or not parity_ok(A):
return None
ra = counted(A)
if ra is None or ra["nsol"] < 8:
return None
target = graceful_target(A)
if target is None:
return None
cl = make_unique(A, target, rng)
if cl is None or len(cl) > max_clues:
return None
B = with_clues(A, cl)
gr = grade(B)
if not gr["solved"] or gr["tier"] > 2:
return None
return {"A": A, "B": B, "target": target, "score": (len(cl), gr["tier"], len(B.sand_cells())),
"facts": {"A_rakings": ra["nsol"], "grooves": len(cl), "tier": gr["tier"]}}
def make_pebbles(rng):
"""A: a garden pinned down by a few grooves. B: the same garden, with a pebble in each groove's cell (it only says
straight or turn), plus whatever pebbles are needed for one raking again."""
c = make_grooves(rng, max_clues=3)
if c is None:
return None
A, target = c["B"], c["target"]
base = copy.deepcopy(A); base.clues = {}
gl = glyphs(base, target)
marks0 = {cell: ("S" if set(gl[cell]) in ({"E", "W"}, {"N", "S"}) else "B") for cell in A.clues}
B0 = with_marks(base, marks0)
extra = make_unique_marks(B0, target, rng)
if extra is None or len(marks0) + len(extra) > 6:
return None
B = with_marks(B0, extra)
gr = grade(B)
if not gr["solved"] or gr["tier"] > 2:
return None
kinds = set(B.marks.values()) # an introduction should show both kinds of pebble
return {"A": A, "B": B, "score": (len(kinds) < 2, len(extra), gr["tier"], len(B.sand_cells())),
"facts": {"grooves": len(A.clues), "pebbles": len(B.marks), "kinds": len(kinds), "tier": gr["tier"]}}
def make_wide(rng):
"""A: an ordinary garden whose every par raking turns back in open sand. B: the same garden with the wide rake."""
H, W = rng.choice([(4, 5), (5, 4), (5, 5), (5, 6), (4, 6)])
A = garden_layout(H, W, rng, n_pieces=1, max_frac=0.12)
if A is None or not parity_ok(A):
return None
ra = counted(A)
if ra is None or ra["nsol"] < 3:
return None
pa = min(ra["hist"])
if not all(open_u_turn(A, d) for d in par_rakings(A, pa)):
return None
B = copy.deepcopy(A); B.nohairpin = 2; B.braced_start = True
rb = B.solve(mode=0, cap=2)
if rb["nsol"] != 1:
return None
gr = grade(B)
if not gr["solved"] or gr["tier"] > 3:
return None
return {"A": A, "B": B, "score": (gr["tier"], gr["trials"] or 0, len(B.sand_cells())),
"facts": {"A_rakings": ra["nsol"], "A_par": pa, "tier": gr["tier"]}}
def side_options(g):
"""Every cell of the north, east or west wall where a side gate could go (not the veranda gate's cell)."""
out = [((0, c), "N") for c in range(g.W) if g.rows[0][c] == "."]
out += [((r, 0), "W") for r in range(g.H - 1) if g.rows[r][0] == "."]
out += [((r, g.W - 1), "E") for r in range(g.H - 1) if g.rows[r][g.W - 1] == "."]
return [o for o in out if o[0] != (g.gate[0], g.gate[1])]
def make_side(rng):
"""A: a small garden for the wide rake with a few rakings. B: the same garden with a side gate, which leaves exactly one
of them, the one that begins there. (B's rakings are always among A's: the side gate's cell touches a wall, so a
raking that begins there is a valid raking of A. So A must have more than one.) Kept only if B's raking is not a par
raking of A: the plan that was best before has to change."""
H, W = rng.choice([(5, 5), (5, 6), (6, 5), (6, 6)])
A = garden_layout(H, W, rng, n_pieces=1, max_frac=0.12)
if A is None:
return None
A.nohairpin = 2; A.braced_start = True
ra = A.solve(mode=0, cap=10**4)
if not ra["complete"] or ra["capped"] or not (2 <= ra["nsol"] <= 8):
return None
pa = min(ra["hist"])
best = None
for cell, side in side_options(A):
B = copy.deepcopy(A); B.end, B.end_side = cell, side
rb = B.solve(mode=0, cap=2, stream=True)
if rb["nsol"] != 1:
continue
d = rb["sols"][0]["draw"]
if turns_of(A, d) == pa:
continue
gb = grade(B)
if not gb["solved"] or gb["tier"] > 4:
continue
c = {"A": A, "B": B, "score": (gb["tier"], gb["trials"] or 0, len(B.sand_cells()), ra["nsol"]),
"facts": {"A_rakings": ra["nsol"], "A_par": pa, "B_turns": turns_of(B, d), "B_tier": gb["tier"]}}
if best is None or c["score"] < best["score"]:
best = c
return best
def make_ring(rng):
"""A small first garden with a ring the abbot has finished, pinned down by at most two grooves."""
H, W = rng.choice([(5, 6), (6, 5), (6, 6)])
g = garden_layout(H, W, rng, closed_ring=True, n_pieces=1, max_frac=0.1)
if g is None or not parity_ok(g):
return None
if counted(g) is None:
return None
target = graceful_target(g)
if target is None:
return None
cl = make_unique(g, target, rng)
if cl is None or len(cl) > 2:
return None
B = with_clues(g, cl)
gr = grade(B)
if not gr["solved"] or gr["tier"] > 2:
return None
return {"B": B, "score": (len(cl), gr["tier"], len(B.sand_cells())), "facts": {"grooves": len(cl), "tier": gr["tier"]}}
MAKERS = {"ripple": make_ripple, "band": make_band, "grooves": make_grooves, "pebbles": make_pebbles,
"wide": make_wide, "side": make_side, "ring": make_ring}
BUDGET = {"ripple": 120, "band": 120, "grooves": 120, "pebbles": 180, "wide": 240, "side": 420, "ring": 120}
def run(args):
name, seed, budget = args
return name, search(MAKERS[name], budget or BUDGET[name], random.Random(seed))
src/poems.py — The poems: one classical waka or haiku for each garden, and one for the practice garden, shown when the garden is
"""The poems: one classical waka or haiku for each garden, and one for the practice garden, shown when the garden is
raked. poems/samon-poems.json is the curated selection (see its notes); attach() gives each garden the compact form the
page embeds, and returns the practice garden's."""
import json
PATH = '/home/claude/zen/poems/samon-poems.json'
def load():
return json.load(open(PATH))
def compact(p):
src = p.get("display_source")
if src is None:
src = " ".join(str(x) for x in (p.get("collection"), p.get("poem_number")) if x)
return {"en": p["translation"], "ja": p["japanese"], "poet": p["poet"], "poet_ja": p.get("author_japanese", ""),
"dates": p.get("author_dates", ""), "src": src, "url": p.get("source_url") or ""}
def attach(gardens):
D = load()
missing = [g["name"] for g in gardens if g["name"] not in D["gardens"]]
assert not missing, f"gardens without a poem: {missing}"
for g in gardens:
p = D["poems"][D["gardens"][g["name"]]]
assert p["translation"].strip() and p["japanese"].strip(), g["name"]
g["poem"] = compact(p)
return compact(D["poems"][D["practice"]])
src/preview.py
import json, sys
from levels import from_dict
from render import garden_svg, hstack, save
L = json.load(open('/home/claude/zen/out/levels.json'))
for key, lv in L.items():
g = from_dict(lv['garden'])
draw = [tuple(c) for c in lv['intended']]
sv = hstack([garden_svg(g, label=f"{key}: puzzle", seed=3), garden_svg(g, draw=draw, label=f"{lv['intended_turns']} turns (par {lv['par']}), {lv['n_rakings']} rakings", seed=3)])
save(sv, f"/home/claude/zen/fig/prev_{key}.png", scale=1.0)
print("done")
src/render.py — SVG/PNG figures of gardens, puzzles and rakings (no external drawing libraries).
"""render.py: SVG/PNG figures of gardens, puzzles and rakings (no external drawing libraries)."""
from __future__ import annotations
import math
import random
from zg import Garden, glyphs, DIRS, DR, DC, OPP
S = 44 # cell size (px)
WALL = 14 # wall thickness
TINES = (0.2, 0.5, 0.8)
SAND = "#eeefec"
SAND_DOT = "#dcdedb"
GROOVE = "#8e928d"
GROOVE_HI = "#ffffff"
STONE = "#4a4d4b"
STONE_HI = "#6b6f6c"
WALLC = "#2f3130"
INK = "#1d1f1e"
SEAL = "#c23b22"
MARK = "#5b7a99" # puzzle markings (bands, rings) in a cool slate blue
def _xy(r, c, ox, oy):
return ox + c * S, oy + r * S
def _stone_path(cx, cy, rad, rng, sides=9):
pts = []
phase = rng.random() * math.tau
for i in range(sides):
a = phase + i * math.tau / sides
rr = rad * (0.78 + 0.3 * rng.random())
pts.append((cx + rr * math.cos(a), cy + rr * math.sin(a) * 0.86))
# smooth closed path through midpoints (quadratic)
d = ""
n = len(pts)
for i in range(n):
p0, p1 = pts[i], pts[(i + 1) % n]
mid = ((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2)
if i == 0:
pm = ((pts[-1][0] + p0[0]) / 2, (pts[-1][1] + p0[1]) / 2)
d += f"M{pm[0]:.1f},{pm[1]:.1f} "
d += f"Q{p0[0]:.1f},{p0[1]:.1f} {mid[0]:.1f},{mid[1]:.1f} "
return d + "Z"
def _components(g: Garden, sym_set):
"""Connected components (4-neighbour) of blocked cells, for drawing multi-cell stones."""
seen = set()
comps = []
for r in range(g.H):
for c in range(g.W):
if g.rows[r][c] in sym_set and (r, c) not in seen:
comp = []
stack = [(r, c)]
seen.add((r, c))
while stack:
a, b = stack.pop()
comp.append((a, b))
for d in DIRS:
nb = (a + DR[d], b + DC[d])
if 0 <= nb[0] < g.H and 0 <= nb[1] < g.W and g.rows[nb[0]][nb[1]] == g.rows[r][c] and nb not in seen:
seen.add(nb)
stack.append(nb)
comps.append(comp)
return comps
def _groove_cell(x, y, dirs, start=False, cls_color=GROOVE):
"""SVG path data for the tines through one cell; dirs is a set of 1-2 directions."""
out = []
dirs = set(dirs)
if len(dirs) == 2 and dirs in ({"E", "W"}, {"N", "S"}):
for o in TINES:
if dirs == {"E", "W"}:
out.append(f"M{x:.1f},{y + o * S:.1f} H{x + S:.1f}")
else:
out.append(f"M{x + o * S:.1f},{y:.1f} V{y + S:.1f}")
elif len(dirs) == 2:
# corner: arc centred on the cell corner shared by the two sides
cx = x + (S if "E" in dirs else 0)
cy = y + (S if "S" in dirs else 0)
for o in TINES:
rad = o * S if True else 0
# point on first side and second side
pts = []
for d in sorted(dirs):
if d in "NS":
yy = y + (0 if d == "N" else S)
xx = cx + (-rad if cx > x else rad)
pts.append((xx, yy))
else:
xx = x + (0 if d == "W" else S)
yy = cy + (-rad if cy > y else rad)
pts.append((xx, yy))
(x1, y1), (x2, y2) = pts
out.append(f"M{x1:.1f},{y1:.1f} A{rad:.1f},{rad:.1f} 0 0 {_sweep(x1, y1, x2, y2, cx, cy)} {x2:.1f},{y2:.1f}")
else:
(d,) = tuple(dirs)
# rake start: tines run from the edge in direction d back to just past the centre
for o in TINES:
if d in "EW":
x_edge = x + (S if d == "E" else 0)
x_end = x + (0.22 * S if d == "E" else 0.78 * S)
out.append(f"M{x_edge:.1f},{y + o * S:.1f} H{x_end:.1f}")
else:
y_edge = y + (S if d == "S" else 0)
y_end = y + (0.22 * S if d == "S" else 0.78 * S)
out.append(f"M{x + o * S:.1f},{y_edge:.1f} V{y_end:.1f}")
return out
def _sweep(x1, y1, x2, y2, cx, cy):
# choose the short arc: sign of cross product of (p1-c) x (p2-c)
cr = (x1 - cx) * (y2 - cy) - (y1 - cy) * (x2 - cx)
return 1 if cr > 0 else 0
def garden_svg(g: Garden, draw=None, show_marks=True, show_clue_grooves=True, title=None, footprints=None,
seed=1, label=None, sublabel=None, highlight_start=True, show_gate_arrow=True,
ghost_path=None, glyph_override=None, marks_svg=None, checker=False, monk=None, show_gate=True):
"""Return an SVG string for one garden panel.
draw: solution in drawing order (gate first) -> rendered as raked grooves.
footprints: partial path (drawing order) rendered as footprints."""
rng = random.Random(seed)
top = 30 if title else 8
bottom = 46 if (label or sublabel) else 16
ox, oy = WALL + 10, top + WALL
width = g.W * S + 2 * WALL + 20
height = g.H * S + 2 * WALL + top + bottom
el = []
el.append(f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">')
el.append(f'<rect x="0" y="0" width="{width}" height="{height}" fill="#ffffff"/>')
if title:
el.append(f'<text x="{width/2:.1f}" y="20" font-family="Georgia, serif" font-size="15" text-anchor="middle" fill="{INK}">{title}</text>')
# wall
el.append(f'<rect x="{ox - WALL}" y="{oy - WALL}" width="{g.W * S + 2 * WALL}" height="{g.H * S + 2 * WALL}" fill="{WALLC}" rx="2"/>')
# sand (one rectangle: per-cell rects leave hairline seams when rasterised at fractional scales)
el.append(f'<rect x="{ox}" y="{oy}" width="{g.W * S}" height="{g.H * S}" fill="{SAND}"/>')
# unraked texture: sparse dots on cells without grooves
raked = set()
gl = {}
if draw:
gl = glyphs(g, draw)
side = getattr(g, "end_side", None)
if side and g.end is not None and tuple(draw[-1]) == tuple(g.end):
gl[tuple(g.end)] = gl[tuple(g.end)] + side # the stroke enters through the side gate
raked = set(gl)
if glyph_override is not None:
gl = dict(glyph_override)
raked = set(gl)
if checker:
for r in range(g.H):
for c in range(g.W):
if (r + c) % 2 == 0 and g.is_sand(r, c):
x, y = _xy(r, c, ox, oy)
el.append(f'<rect x="{x}" y="{y}" width="{S}" height="{S}" fill="#d6d9d4"/>')
if show_clue_grooves:
for cell, dirs in g.clues.items():
raked.add(cell)
for r in range(g.H):
for c in range(g.W):
if g.is_sand(r, c) and (r, c) not in raked:
x, y = _xy(r, c, ox, oy)
for _ in range(5):
el.append(f'<circle cx="{x + rng.random() * S:.1f}" cy="{y + rng.random() * S:.1f}" r="0.9" fill="{SAND_DOT}"/>')
# puzzle marks: bands / ichimatsu hatching
if show_marks and not draw:
for r in range(g.H):
for c in range(g.W):
s = g.rows[r][c]
x, y = _xy(r, c, ox, oy)
if s in "h=":
for o in (0.25, 0.5, 0.75):
el.append(f'<line x1="{x}" y1="{y + o * S:.1f}" x2="{x + S}" y2="{y + o * S:.1f}" stroke="{MARK}" stroke-width="1" stroke-dasharray="3 4" opacity="0.8"/>')
elif s in "v|":
for o in (0.25, 0.5, 0.75):
el.append(f'<line x1="{x + o * S:.1f}" y1="{y}" x2="{x + o * S:.1f}" y2="{y + S}" stroke="{MARK}" stroke-width="1" stroke-dasharray="3 4" opacity="0.8"/>')
# band outlines
for comp in _components(g, set("hv=|")):
cs = set(comp)
for (r, c) in comp:
x, y = _xy(r, c, ox, oy)
for d, (x1, y1, x2, y2) in {"N": (x, y, x + S, y), "S": (x, y + S, x + S, y + S),
"W": (x, y, x, y + S), "E": (x + S, y, x + S, y + S)}.items():
if (r + DR[d], c + DC[d]) not in cs:
el.append(f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{MARK}" stroke-width="1.6"/>')
# grooves
paths = []
if gl:
for cell, dirs in gl.items():
x, y = _xy(cell[0], cell[1], ox, oy)
paths += _groove_cell(x, y, dirs)
elif show_clue_grooves:
for cell, dirs in g.clues.items():
x, y = _xy(cell[0], cell[1], ox, oy)
paths += _groove_cell(x, y, set(dirs))
if paths:
d = " ".join(paths)
el.append(f'<path d="{d}" fill="none" stroke="{GROOVE_HI}" stroke-width="2.4" stroke-linecap="butt" transform="translate(-0.9,-0.9)"/>')
el.append(f'<path d="{d}" fill="none" stroke="{GROOVE}" stroke-width="1.9" stroke-linecap="butt"/>')
if ghost_path:
pts = " ".join(f"{ox + c * S + S / 2:.1f},{oy + r * S + S / 2:.1f}" for r, c in ghost_path)
el.append(f'<polyline points="{pts}" fill="none" stroke="{SEAL}" stroke-width="2" stroke-dasharray="5 4" opacity="0.8"/>')
if footprints:
for i, (r, c) in enumerate(footprints):
x, y = _xy(r, c, ox, oy)
cx, cy = x + S / 2, y + S / 2
if i + 1 < len(footprints):
nr, nc = footprints[i + 1]
else:
pr, pc_ = footprints[i - 1]
nr, nc = 2 * r - pr, 2 * c - pc_
ang = math.degrees(math.atan2(nr - r, nc - c))
for side in (-1, 1):
el.append(f'<ellipse cx="0" cy="{side * 5}" rx="6" ry="3" fill="{STONE_HI}" opacity="0.55" '
f'transform="translate({cx:.1f},{cy:.1f}) rotate({ang:.1f}) translate({side * 4},0)"/>')
# ripple-stone marks: dotted circle through the ring
if show_marks and not draw:
for r in range(g.H):
for c in range(g.W):
if g.rows[r][c] == "O":
x, y = _xy(r, c, ox, oy)
cx, cy = x + S / 2, y + S / 2
for rad in (S * 0.95, S * 1.25):
el.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{rad:.1f}" fill="none" stroke="{MARK}" stroke-width="1.3" stroke-dasharray="2 4"/>')
# stones
for comp in _components(g, set("#O")):
rs = [p[0] for p in comp]
cs_ = [p[1] for p in comp]
cx = ox + (min(cs_) + max(cs_) + 1) / 2 * S
cy = oy + (min(rs) + max(rs) + 1) / 2 * S
rad = 0.5 * S * max(max(rs) - min(rs) + 1, max(cs_) - min(cs_) + 1) * 0.92
if len(comp) > 1 and len(comp) != (max(rs) - min(rs) + 1) * (max(cs_) - min(cs_) + 1):
# irregular shape: draw each cell as a rounded blob
for (r, c) in comp:
x, y = _xy(r, c, ox, oy)
el.append(f'<path d="{_stone_path(x + S / 2, y + S / 2, S * 0.5, rng)}" fill="{STONE}"/>')
continue
el.append(f'<path d="{_stone_path(cx, cy + 2, rad * 1.02, rng)}" fill="#c9cbc8" opacity="0.6"/>')
el.append(f'<path d="{_stone_path(cx, cy, rad, rng)}" fill="{STONE}"/>')
el.append(f'<path d="{_stone_path(cx - rad * 0.18, cy - rad * 0.22, rad * 0.45, rng, 7)}" fill="{STONE_HI}" opacity="0.7"/>')
# the abbot's pebbles (shape marks): pale = straight through, dark = turn; small stones off-centre in the cell
if show_marks and not draw:
for (r, c), t in sorted(getattr(g, "marks", {}).items()):
x, y = _xy(r, c, ox, oy)
prng = random.Random(r * 1009 + c * 17 + 5)
px, py, pr = x + S / 2 + 0.17 * S, y + S / 2 - 0.17 * S, 0.14 * S
el.append(f'<path d="{_stone_path(px + 1, py + 1.3, pr * 1.08, random.Random(r * 1009 + c * 17 + 5))}" fill="#c9cbc8" opacity="0.8"/>')
if t == "S":
el.append(f'<path d="{_stone_path(px, py, pr, prng)}" fill="#ffffff" stroke="{STONE}" stroke-width="1.3"/>')
else:
el.append(f'<path d="{_stone_path(px, py, pr, prng)}" fill="{STONE}"/>')
# gate: gap in the wall + shoe-stone outside
gr, gc, gd = g.gate
x, y = _xy(gr, gc, ox, oy)
if gd == "S":
gx, gy, gw, gh = x + 4, y + S, S - 8, WALL
sx, sy = x + S / 2, y + S + WALL + 0
elif gd == "N":
gx, gy, gw, gh = x + 4, y - WALL, S - 8, WALL
sx, sy = x + S / 2, y - WALL
elif gd == "W":
gx, gy, gw, gh = x - WALL, y + 4, WALL, S - 8
sx, sy = x - WALL, y + S / 2
else:
gx, gy, gw, gh = x + S, y + 4, WALL, S - 8
sx, sy = x + S + WALL, y + S / 2
if show_gate:
el.append(f'<rect x="{gx}" y="{gy}" width="{gw}" height="{gh}" fill="{SAND}"/>')
if show_gate and show_gate_arrow:
ax, ay = gx + gw / 2, gy + gh / 2
rot = {"S": 0, "N": 180, "W": 90, "E": -90}[gd]
el.append(f'<path d="M-5,-3 L5,-3 L0,4 Z" fill="{SEAL}" transform="translate({ax:.1f},{ay:.1f}) rotate({rot})"/>')
# two-gate gardens: the side gate through which the stroke must begin
side = getattr(g, "end_side", None)
if g.end is not None and side:
x, y = _xy(g.end[0], g.end[1], ox, oy)
if side == "N":
bx, by, bw, bh, rot = x + 4, y - WALL, S - 8, WALL, 0
elif side == "S":
bx, by, bw, bh, rot = x + 4, y + S, S - 8, WALL, 180
elif side == "W":
bx, by, bw, bh, rot = x - WALL, y + 4, WALL, S - 8, -90
else:
bx, by, bw, bh, rot = x + S, y + 4, WALL, S - 8, 90
el.append(f'<rect x="{bx}" y="{by}" width="{bw}" height="{bh}" fill="{SAND}"/>')
el.append(f'<path d="M-5,-3 L5,-3 L0,4 Z" fill="none" stroke="{SEAL}" stroke-width="1.5" transform="translate({bx + bw / 2:.1f},{by + bh / 2:.1f}) rotate({rot})"/>')
if draw and highlight_start:
r, c = draw[-1]
x, y = _xy(r, c, ox, oy)
el.append(f'<circle cx="{x + S / 2:.1f}" cy="{y + S / 2:.1f}" r="3.2" fill="{SEAL}"/>')
if monk is not None:
r, c = monk
x, y = _xy(r, c, ox, oy)
el.append(f'<circle cx="{x + S / 2:.1f}" cy="{y + S / 2:.1f}" r="9" fill="{INK}" stroke="#ffffff" stroke-width="2"/>')
el.append(f'<circle cx="{x + S / 2:.1f}" cy="{y + S / 2 - 1:.1f}" r="4.2" fill="#e8e3da"/>')
if marks_svg:
el.append(marks_svg(ox, oy))
if label:
el.append(f'<text x="{width/2:.1f}" y="{oy + g.H * S + WALL + 20:.1f}" font-family="Georgia, serif" font-size="14" text-anchor="middle" fill="{INK}">{label}</text>')
if sublabel:
el.append(f'<text x="{width/2:.1f}" y="{oy + g.H * S + WALL + 37:.1f}" font-family="Georgia, serif" font-size="12" font-style="italic" text-anchor="middle" fill="#555">{sublabel}</text>')
el.append("</svg>")
return "\n".join(el)
def hstack(svgs, gap=10):
"""Place several SVG panels side by side in one SVG."""
import re
dims = []
bodies = []
for s in svgs:
m = re.search(r'width="([\d.]+)" height="([\d.]+)"', s)
w, h = float(m.group(1)), float(m.group(2))
dims.append((w, h))
body = s.split(">", 1)[1].rsplit("</svg>", 1)[0]
bodies.append(body)
W = sum(w for w, _ in dims) + gap * (len(svgs) - 1)
H = max(h for _, h in dims)
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W:.0f}" height="{H:.0f}" viewBox="0 0 {W:.0f} {H:.0f}">',
f'<rect x="0" y="0" width="{W:.0f}" height="{H:.0f}" fill="#ffffff"/>']
x = 0
for (w, h), body in zip(dims, bodies):
out.append(f'<g transform="translate({x:.1f},{(H - h) / 2:.1f})">{body}</g>')
x += w + gap
out.append("</svg>")
return "\n".join(out)
def save(svg, path_png, path_svg=None, scale=2.0):
import cairosvg
if path_svg:
with open(path_svg, "w") as f:
f.write(svg)
cairosvg.svg2png(bytestring=svg.encode(), write_to=path_png, scale=scale)
src/search2.py — Second-pass searches: prettier unique gardens for the wide rake and two-gate mechanics.
"""Second-pass searches: prettier unique gardens for the wide rake and two-gate mechanics."""
import json, random, sys, time
from multiprocessing import Pool
from gen import random_layout, parity_ok
from zg import Garden, turns_of, hairpin_list
from grader import grade
from levels import to_dict
def wide_job(seed):
rng = random.Random(seed)
out = []
t0 = time.time()
while time.time() - t0 < 240:
H, W = rng.choice([(7, 7), (7, 8), (8, 8)])
g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g): continue
g.nohairpin = 2; g.braced_start = True
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1: continue
d = r["sols"][0]["draw"]
dens = turns_of(g, d) / len(d)
if dens > 0.45: continue
gr = grade(g)
if gr["solved"] and gr["tier"] >= 3:
out.append((dens, gr["trials"], to_dict(g), d))
return out
def two_job(seed):
rng = random.Random(seed)
out = []
t0 = time.time()
while time.time() - t0 < 240:
H, W = rng.choice([(7, 7), (6, 8), (7, 8)])
g = random_layout(H, W, rng.choice([1, 2, 3]), rng, gate_side="S", min_gap=1)
if g is None: continue
side = rng.choice("EWN")
if side == "E": cells = [(r, W - 1) for r in range(H - 2)]
elif side == "W": cells = [(r, 0) for r in range(H - 2)]
else: cells = [(0, c) for c in range(W)]
cells = [c for c in cells if g.rows[c[0]][c[1]] == "."]
if not cells: continue
g.end = rng.choice(cells)
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1: continue
d = r["sols"][0]["draw"]
dens = turns_of(g, d) / len(d)
ub = sum(1 for *_, br in hairpin_list(g, d) if not br)
if dens > 0.42 or ub > 0: continue
gr = grade(g)
if gr["solved"]:
dd = to_dict(g); dd["end_side"] = side
out.append((dens, gr["tier"], gr["trials"], dd, d))
return out
if __name__ == "__main__":
kind = sys.argv[1]
with Pool(2) as p:
res = p.map(wide_job if kind == "wide" else two_job, [11, 12])
res = [x for r in res for x in r]
res.sort(key=lambda x: x[0])
print(kind, "found", len(res))
for x in res[:8]:
print(x[:3] if kind == "wide" else x[:3], x[-2]["rows"] if kind == "wide" else x[3]["rows"])
json.dump(res[:20], open(f"/home/claude/zen/out/search2_{kind}.json", "w"))
src/search3.py
import json, random, sys, time
from multiprocessing import Pool
from gen import random_layout, parity_ok
from zg import turns_of, hairpin_list
from grader import grade
from levels import to_dict
def wide_job(seed):
rng = random.Random(seed)
out = {}
t0 = time.time()
while time.time() - t0 < float(sys.argv[2]):
H, W = rng.choice([(7, 7), (7, 8), (8, 8)])
k = rng.choice([2, 3, 4])
g = random_layout(H, W, k, rng, gate_side="S", min_gap=1)
if g is None or not parity_ok(g): continue
g.nohairpin = 2; g.braced_start = True
key = (tuple(g.rows), tuple(g.gate))
if key in out: continue
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1: continue
d = r["sols"][0]["draw"]
dens = turns_of(g, d) / len(d)
if dens > 0.40: continue
gr = grade(g)
if gr["solved"] and gr["tier"] >= 3:
out[key] = (dens, gr["trials"], k, to_dict(g), d)
return list(out.values())
if __name__ == "__main__":
with Pool(2) as p:
res = p.map(wide_job, [101, 202])
res = [x for r in res for x in r]
res.sort(key=lambda x: (x[0], -x[2]))
print("found", len(res))
for x in res[:12]:
print(round(x[0],3), x[1], x[2], x[3]["rows"], x[3]["gate"])
json.dump(res, open("/home/claude/zen/out/search3_wide.json", "w"))
src/search4.py
import json, random, sys, time
from multiprocessing import Pool
from gen import random_layout, parity_ok
from zg import turns_of, hairpin_list
from grader import grade
from levels import to_dict
def job(seed):
rng = random.Random(seed)
out = {}
t0 = time.time()
while time.time() - t0 < float(sys.argv[1]):
H, W = rng.choice([(7, 7), (7, 8), (8, 8)])
k = rng.choice([1, 2, 3])
g = random_layout(H, W, k, rng, gate_side="S", min_gap=1)
if g is None: continue
side = rng.choice("EWN")
if side == "E": cells = [(r, W - 1) for r in range(H - 2)]
elif side == "W": cells = [(r, 0) for r in range(H - 2)]
else: cells = [(0, c) for c in range(W)]
cells = [c for c in cells if g.rows[c[0]][c[1]] == "."]
if not cells: continue
g.end = rng.choice(cells)
wide = rng.random() < 0.5
if wide: g.nohairpin = 2; g.braced_start = True
key = (tuple(g.rows), tuple(g.gate), g.end, wide)
if key in out: continue
r = g.solve(mode=0, cap=2)
if r["nsol"] != 1: continue
d = r["sols"][0]["draw"]
dens = turns_of(g, d) / len(d)
ub = sum(1 for *_, br in hairpin_list(g, d) if not br)
if dens > 0.40 or ub > 0: continue
gr = grade(g)
if gr["solved"]:
dd = to_dict(g); dd["end_side"] = side
out[key] = (dens, gr["tier"], gr["trials"], wide, dd, d)
return list(out.values())
if __name__ == "__main__":
with Pool(2) as p:
res = p.map(job, [7, 8])
res = [x for r in res for x in r]
res.sort(key=lambda x: x[0])
print("found", len(res), "wide:", sum(x[3] for x in res))
for x in res[:12]:
print(round(x[0],3), x[1], x[2], x[3], x[4]["rows"], x[4]["gate"], x[4]["end"], x[4]["end_side"])
json.dump(res, open("/home/claude/zen/out/search4_two.json", "w"))
src/search_finale.py
import json, random, sys, time
from multiprocessing import Pool
from zg import Garden, turns_of, hairpin_list
from gen import parity_ok
from levels import to_dict
def polyomino(k, rng):
cells = {(0, 0)}
while len(cells) < k:
r, c = rng.choice(sorted(cells))
d = rng.choice([(0,1),(1,0),(0,-1),(-1,0)])
cells.add((r + d[0], c + d[1]))
mr = min(r for r, c in cells); mc = min(c for r, c in cells)
return {(r - mr, c - mc) for r, c in cells}
def layout(H, W, sizes, rng):
rows = [["."] * W for _ in range(H)]
occupied = set()
for k in sizes:
for _ in range(200):
p = polyomino(k, rng)
ph = max(r for r, c in p) + 1; pw = max(c for r, c in p) + 1
if ph > min(3, H - 2) or pw > W - 2: continue
r0 = rng.randint(1, H - ph - 1); c0 = rng.randint(1, W - pw - 1)
cells = {(r0 + r, c0 + c) for r, c in p}
halo = {(r + dr, c + dc) for r, c in cells for dr in (-2, -1, 0, 1, 2) for dc in (-2, -1, 0, 1, 2)}
if halo & occupied: continue
for r, c in cells: rows[r][c] = "#"
occupied |= cells
break
else:
return None
return rows
def job(seed):
rng = random.Random(seed); out = []; t0 = time.time(); tried = 0
H, W = int(sys.argv[2]), int(sys.argv[3])
while time.time() - t0 < float(sys.argv[1]):
sizes = [5, 3, 3, 2, 2]; rng.shuffle(sizes)
rows = layout(H, W, sizes, rng)
if rows is None: continue
gc = rng.choice([c for c in range(W) if rows[H-1][c] == "."])
g = Garden(["".join(r) for r in rows], (H - 1, gc, "S"), nohairpin=2, braced_start=True)
tried += 1
if not parity_ok(g): continue
r = g.solve(mode=0, cap=2, nodelimit=5 * 10**7)
if r["nsol"] != 1 or not r["complete"]: continue
d = r["sols"][0]["draw"]
dens = turns_of(g, d) / len(d)
out.append((dens, to_dict(g), d, sizes))
return out, tried
if __name__ == "__main__":
with Pool(2) as p:
res = p.map(job, [int(sys.argv[4]), int(sys.argv[4]) + 1])
rows = [x for r, _ in res for x in r]; tried = sum(t for _, t in res)
rows.sort(key=lambda x: x[0])
print("tried", tried, "unique", len(rows))
for x in rows[:6]:
print(round(x[0], 3), x[3], x[1]["rows"], x[1]["gate"])
json.dump(rows[:30], open(f"/home/claude/zen/out/finale_{sys.argv[2]}x{sys.argv[3]}.json", "w"))
src/search_finale2.py
import json, random, sys, time
from zg import Garden, turns_of, hairpin_list
from gen import parity_ok
from levels import to_dict
from zcp import optimize
from search_finale import layout
rng = random.Random(int(sys.argv[3]))
H, W = int(sys.argv[1]), int(sys.argv[2])
t0 = time.time(); out = []; tried = 0
while time.time() - t0 < float(sys.argv[4]):
sizes = [5, 3, 3, 2, 2]; rng.shuffle(sizes)
rows = layout(H, W, sizes, rng)
if rows is None: continue
gc = rng.choice([c for c in range(W) if rows[H-1][c] == "."])
g = Garden(["".join(r) for r in rows], (H - 1, gc, "S"))
tried += 1
if not parity_ok(g): continue
if g.solve(mode=0, cap=1, nodelimit=10**7)['nsol'] == 0: continue
free = optimize(g, time_limit=60, workers=2)
if free[1] != 'OPTIMAL': continue
grace = optimize(g, nohairpin=2, time_limit=60, workers=2)
ok = grace[1] == 'OPTIMAL' and grace[0] == free[0]
print(f"tried {tried} par {free[0]} graceful {grace[0]} {grace[1]} ok={ok} ({time.time()-t0:.0f}s)", flush=True)
if ok:
out.append((free[0], to_dict(g), list(reversed(grace[2])), sizes))
json.dump(out, open(f"/home/claude/zen/out/finale2_{H}x{W}_{sys.argv[3]}.json", "w"))
print("found", len(out))
src/seed_garden_names.py — One-time seeding of out/garden_names.json: the names the gardens had in the 136-garden campaign (the one Astra chose
"""One-time seeding of out/garden_names.json: the names the gardens had in the 136-garden campaign (the one Astra chose
poems for), recovered by matching each current garden's layout to that campaign's per-level record (size, gate, pars and
counts, stones, marks, and side gate; side-gate pars are matched without par, since the bend inside a side gate now
counts as a turn). From now on campaign.py names a garden by its layout, so a garden keeps its name (and its poem, and a
player's saved progress) when the campaign is rearranged. The two world-7 ring gardens generated since then take the
names of the two ring gardens they replaced."""
import json, collections
A = json.load(open('/home/claude/zen/poems/astra-samon-poem-selection.json'))
C = json.load(open('/home/claude/zen/out/campaign.json'))
def full_key(d):
return json.dumps([d["rows"], d["gate"], d["end"], d.get("end_side"), d["nohairpin"], bool(d["braced_start"]),
sorted(d["clues"].items()), sorted((d.get("marks") or {}).items())])
def sig_new(e, par=True):
d = e["garden"]; rows = d["rows"]; s = "".join(rows); marks = d.get("marks") or {}
return (len(rows), len(rows[0]), tuple(d["gate"]), e.get("par") if par else None, s.count('#'), s.count('O'), s.count('R'),
s.count('h'), s.count('v'), s.count('='), s.count('|'), len(d["clues"]), sum(v == 'S' for v in marks.values()),
sum(v == 'B' for v in marks.values()), d["nohairpin"] == 2, tuple(d["end"]) if d["end"] else None)
def sig_old(L, par=True):
m = L["mechanics"]
return (m["rows"], m["columns"], tuple(m["gate_zero_based"]), m["par_turns"] if par else None, m["ordinary_stone_cells"],
m["ripple_stones"], m["protected_cells"], m["horizontal_band_cells"], m["vertical_band_cells"], m["horizontal_block_cells"],
m["vertical_block_cells"], m["given_grooves"], m["pale_pebbles"], m["dark_pebbles"], m["wide_rake"],
tuple(m["second_endpoint_zero_based"]) if m["second_endpoint_zero_based"] else None)
P = {p["name"]: p for p in json.load(open('/home/claude/zen/out/proto_gardens.json'))}
for e in C:
e["par"] = P[e["name"]]["par"]
old = collections.defaultdict(list)
for L in A["levels"]:
old[sig_old(L, False)].append(L)
reg, used = {}, set()
for e in C:
cands = [L for L in old.get(sig_new(e, False), []) if L["name"] not in used]
if not cands:
continue
exact = [L for L in cands if L["mechanics"]["par_turns"] == e["par"]]
side = e["garden"]["end"] is not None
cands = exact if exact else ([L for L in cands if L["mechanics"]["par_turns"] + 1 == e["par"]] if side else [])
if len(cands) > 1: # two 5x5 wide-rake gardens with a two-cell stone on the right wall: told apart by Astra's description
rows = e["garden"]["rows"]
above = rows[1][-1] == "#"
cands = [L for L in cands if ("above the midpoint" in L["description"]) == above]
assert len(cands) == 1, (e["name"], [L["name"] for L in cands])
reg[full_key(e["garden"])] = cands[0]["name"]; used.add(cands[0]["name"])
# the two world-7 ring gardens that replaced "Lantern festival" and "Dark water" (the ring spec was regenerated)
rings7 = [e for e in C if e["world"] == 7 and "R" in "".join(e["garden"]["rows"]) and full_key(e["garden"]) not in reg]
assert len(rings7) == 2 and {"Lantern festival", "Dark water"}.isdisjoint(used), [e["name"] for e in rings7]
rings7.sort(key=lambda e: (e["role"] != "intro", e["name"]))
for e, nm in zip(rings7, ("Lantern festival", "Dark water")):
reg[full_key(e["garden"])] = nm; used.add(nm)
missing = [L["name"] for L in A["levels"] if L["name"] not in used]
print(len(reg), "layouts named;", "old names with no layout now:", missing)
json.dump([{"name": v, "key": k} for k, v in sorted(reg.items(), key=lambda kv: kv[1])], open('/home/claude/zen/out/garden_names.json', 'w'), indent=0)
src/sound_pieces.py — The recordings carried in the page, as HTML for the builders (proto/build.py, standalone/build_standalone.py).
"""The recordings carried in the page, as HTML for the builders (proto/build.py, standalone/build_standalone.py).
out/sound_pieces.json (sound/make_sound_bundle.py) holds each encoded recording with its credits. Each becomes an inert
<script type="text/plain" class="piece"> whose text is a data: URL; the page reads them (loadPieces in the template), and
Gwern's gwtar tool moves them into its archive, leaving a path the page resolves through the gwtar loader (samonAsset).
"""
import html, json, os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PIECES = os.path.join(ROOT, "out", "sound_pieces.json")
def load():
return json.load(open(PIECES)) if os.path.exists(PIECES) else []
def pieces_html(pieces=None):
pieces = load() if pieces is None else pieces
if not pieces:
print("warning: no recordings (out/sound_pieces.json missing): the page will have no shakuhachi")
return "<!-- no recordings -->"
out = []
for i, p in enumerate(pieces):
attrs = " ".join(f'data-{k}="{html.escape(str(p[k]), quote=True)}"' for k in ("title", "player", "license", "source", "mime", "gain", "dur"))
out.append(f'<script type="text/plain" class="piece" id="piece-{i}" {attrs}>data:{p["mime"]};base64,{p["b64"]}</script>')
return "\n".join(out)
src/test_crosscheck.py — Cross-check the C solver against the brute-force Python enumerator on random small gardens.
"""Cross-check the C solver against the brute-force Python enumerator on random small gardens."""
import random
import sys
from zg import Garden, brute_force, check_solution
random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 1)
def random_garden():
H = random.randint(3, 5)
W = random.randint(3, 5)
while H * W > 22:
W -= 1
rows = [["." for _ in range(W)] for _ in range(H)]
for _ in range(random.randint(0, 3)):
r, c = random.randrange(H), random.randrange(W)
rows[r][c] = "#"
# maybe a ripple stone
if random.random() < 0.3 and H >= 3 and W >= 3:
r, c = random.randint(1, H - 2), random.randint(1, W - 2)
for rr in range(r - 1, r + 2):
for cc in range(c - 1, c + 2):
rows[rr][cc] = "."
rows[r][c] = "O"
# maybe a wave band
if random.random() < 0.3:
sym = random.choice("hv")
r0, c0 = random.randrange(H), random.randrange(W)
for rr in range(r0, min(H, r0 + random.randint(1, 2))):
for cc in range(c0, min(W, c0 + random.randint(1, 3))):
if rows[rr][cc] == ".":
rows[rr][cc] = sym
# gate on the border
border = [(r, c) for r in range(H) for c in range(W) if (r in (0, H - 1) or c in (0, W - 1)) and rows[r][c] in ".hv"]
if not border:
return None
gr, gc = random.choice(border)
sides = [s for s, ok in (("N", gr == 0), ("S", gr == H - 1), ("W", gc == 0), ("E", gc == W - 1)) if ok]
clues = {}
if random.random() < 0.3:
sand = [(r, c) for r in range(H) for c in range(W) if rows[r][c] == "."]
if sand:
cell = random.choice(sand)
clues[cell] = random.choice(["EW", "NS", "NE", "ES", "SW", "NW"])
g = Garden(["".join(r) for r in rows], (gr, gc, random.choice(sides)), clues=clues,
nohairpin=random.choice([0, 0, 1, 2]), braced_turns=random.random() < 0.25, braced_start=random.random() < 0.3)
if random.random() < 0.15:
sand = g.sand_cells()
g.end = random.choice(sand)
return g
ok = bad = 0
for trial in range(int(sys.argv[2]) if len(sys.argv) > 2 else 150):
g = random_garden()
if g is None:
continue
try:
g.ring_sets()
except ValueError:
continue
res = g.solve(mode=0, cap=10**9)
n_bf, hist_bf = brute_force(g)
sols_ok = all(check_solution(g, s["draw"]) for s in res["sols"])
if res["nsol"] != n_bf or res["hist"] != hist_bf or not sols_ok:
bad += 1
print("MISMATCH", g, res["nsol"], n_bf, res["hist"], hist_bf, sols_ok)
else:
ok += 1
print("ok", ok, "bad", bad)
src/test_grader.py
import random, time
from gen import random_layout
from clues import make_unique, with_clues
from grader import grade, Board, State
from zg import glyphs
rng = random.Random(3)
tiers = {}
t0 = time.time()
done = 0
while done < 40:
variant = rng.choice(["plain", "wide"])
g = random_layout(6, 6, rng.choice([0, 1, 2, 3]), rng, gate_side="S")
if g is None: continue
if variant == "wide": g.nohairpin = 2; g.braced_start = True
r = g.solve(mode=0, cap=1)
if r["nsol"] == 0: continue
clues = make_unique(g, r["sols"][0]["draw"], rng)
if clues is None: continue
gu = with_clues(g, clues)
ru = gu.solve(mode=0, cap=2)
assert ru["nsol"] == 1
res = grade(gu)
tiers.setdefault((variant, res["tier"]), 0)
tiers[(variant, res["tier"])] += 1
done += 1
print(variant, len(clues), res, flush=True)
print(sorted(tiers.items()), f"{time.time()-t0:.0f}s")
src/test_grader_marks.py — Soundness of the grader's deductions (incl. shape marks): on random gardens, it must never derive a
"""Soundness of the grader's deductions (incl. shape marks): on random gardens, it must never derive a
contradiction when a raking exists, and when it claims a solution, that must be the unique raking."""
import random, sys
from zg import Garden, glyphs
from grader import grade, Board, State, trial_loop, Contradiction
rng = random.Random(int(sys.argv[1]) if len(sys.argv) > 1 else 7)
n_ok = n_bad = n_uniq = n_solved = 0
for t in range(int(sys.argv[2]) if len(sys.argv) > 2 else 300):
H, W = rng.randint(4, 6), rng.randint(4, 6)
rows = [["."] * W for _ in range(H)]
for _ in range(rng.randint(0, 3)):
rows[rng.randrange(H)][rng.randrange(W)] = "#"
if rows[H - 1][0] != ".":
continue
g = Garden(["".join(r) for r in rows], (H - 1, 0, "S"))
sand = g.sand_cells()
if rng.random() < 0.3:
g.nohairpin = 2; g.braced_start = True
if rng.random() < 0.5: # random marks (often unsolvable)
for _ in range(rng.randint(1, 6)):
g.marks[rng.choice(sand)] = rng.choice("SB")
else: # marks taken from one raking (solvable, often unique)
r0 = g.solve(mode=0, cap=200, stream=True)
if not r0["sols"]:
continue
draw = rng.choice(r0["sols"])["draw"]
gl = glyphs(g, draw)
two = [c for c in draw[:-1] if len(gl[c]) == 2]
for c in rng.sample(two, min(len(two), rng.randint(2, 10))):
g.marks[c] = "S" if set(gl[c]) in ({"E", "W"}, {"N", "S"}) else "B"
r = g.solve(mode=0, cap=3, stream=True)
res = grade(g)
bad = False
if r["nsol"] >= 1 and res["tier"] == 0:
bad = True # contradiction although a raking exists
if r["nsol"] == 0 and res["solved"]:
bad = True
if r["nsol"] >= 2 and res["solved"]:
bad = True
if r["nsol"] == 1:
n_uniq += 1
if res["solved"]:
n_solved += 1
# re-derive the grader's final edge set and compare with the solver's raking
B = Board(g); st = State(B, use_global=True); st.propagate()
for depth in range(2):
if st.solved(): break
st = trial_loop(st.copy(), depth, stats=None)
draw = r["sols"][0]["draw"]
want = {B.eid[(B.idx[a], B.idx[b])] for a, b in zip(draw, draw[1:])}
got = {e for e, x in enumerate(st.s) if x == 1}
bad |= want != got
if bad:
n_bad += 1; print("BAD", g, r["nsol"], res)
else:
n_ok += 1
print(f"grader soundness with marks: ok {n_ok} bad {n_bad} (unique {n_uniq}, solved by grader {n_solved})")
src/test_marks.py — Cross-check shape marks ('S' straight / 'B' turn): C solver vs brute force (small gardens, all rules),
"""Cross-check shape marks ('S' straight / 'B' turn): C solver vs brute force (small gardens, all rules),
streamed solutions vs counts, and C vs CP-SAT (medium gardens: counts, histograms, par)."""
import random
import sys
from zg import Garden, brute_force, check_solution
from zcp import count_solutions, optimize
seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1
trials = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
random.seed(seed)
def random_garden(Hr=(3, 5), Wr=(3, 5), maxcells=22, rules=True):
H, W = random.randint(*Hr), random.randint(*Wr)
while H * W > maxcells:
W -= 1
rows = [["." for _ in range(W)] for _ in range(H)]
for _ in range(random.randint(0, 3)):
rows[random.randrange(H)][random.randrange(W)] = "#"
if rules and random.random() < 0.25 and H >= 3 and W >= 3:
r, c = random.randint(1, H - 2), random.randint(1, W - 2)
for rr in range(r - 1, r + 2):
for cc in range(c - 1, c + 2):
rows[rr][cc] = "."
rows[r][c] = "O"
if rules and random.random() < 0.25:
sym = random.choice("hv")
r0, c0 = random.randrange(H), random.randrange(W)
for rr in range(r0, min(H, r0 + random.randint(1, 2))):
for cc in range(c0, min(W, c0 + random.randint(1, 3))):
if rows[rr][cc] == ".":
rows[rr][cc] = sym
border = [(r, c) for r in range(H) for c in range(W) if (r in (0, H - 1) or c in (0, W - 1)) and rows[r][c] in ".hv"]
if not border:
return None
gr, gc = random.choice(border)
sides = [s for s, ok in (("N", gr == 0), ("S", gr == H - 1), ("W", gc == 0), ("E", gc == W - 1)) if ok]
g = Garden(["".join(r) for r in rows], (gr, gc, random.choice(sides)))
sand = g.sand_cells()
if rules:
if random.random() < 0.2:
g.clues[random.choice(sand)] = random.choice(["EW", "NS", "NE", "ES", "SW", "NW"])
g.nohairpin = random.choice([0, 0, 1, 2])
g.braced_start = random.random() < 0.3
if random.random() < 0.15:
g.end = random.choice(sand)
for _ in range(random.choice([1, 1, 2, 3])):
cell = random.choice(sand)
if cell not in g.clues:
g.marks[cell] = random.choice("SB")
try:
g.ring_sets()
except ValueError:
return None
return g
ok = bad = solvable = 0
for t in range(trials):
g = random_garden()
if g is None:
continue
res = g.solve(mode=0, cap=10**9, stream=True)
n_bf, hist_bf = brute_force(g)
draws = [tuple(map(tuple, s["draw"])) for s in res["sols"]]
good = (res["nsol"] == n_bf and res["hist"] == hist_bf and len(draws) == res["nsol"]
and len(set(draws)) == len(draws) and all(check_solution(g, list(d)) for d in draws))
if good:
ok += 1
solvable += res["nsol"] > 0
else:
bad += 1
print("MISMATCH", g, res["nsol"], n_bf, res["hist"], hist_bf)
print(f"small (C vs brute force, streamed sols checked): ok {ok} bad {bad} solvable {solvable}")
# medium gardens: C vs CP-SAT on counts/histograms (when small enough) and on par
ok2 = bad2 = 0
random.seed(seed + 1000)
for t in range(max(20, trials // 50)):
g = random_garden(Hr=(6, 7), Wr=(6, 7), maxcells=49, rules=False)
if g is None:
continue
for _ in range(random.randint(1, 4)):
g.marks[random.choice(g.sand_cells())] = random.choice("SB")
res = g.solve(mode=0, cap=3000)
if not res["complete"] or res["capped"]:
continue
if res["nsol"] == 0:
st = optimize(g, time_limit=60)[1]
good = st == "INFEASIBLE"
else:
n_cp, hist_cp, st = count_solutions(g, limit=10000)
par = optimize(g, time_limit=60)
good = n_cp == res["nsol"] and hist_cp == res["hist"] and par[0] == min(res["hist"]) and par[1] == "OPTIMAL"
if good:
ok2 += 1
else:
bad2 += 1
print("CP-SAT MISMATCH", g, res["nsol"], res["hist"])
print(f"medium (C vs CP-SAT): ok {ok2} bad {bad2}")
src/test_side_pivot.py — Cross-check the two rule changes of 2026-10-04 across all three solvers:
"""Cross-check the two rule changes of 2026-10-04 across all three solvers:
(1) with a side gate, the bend where the stroke enters through it counts as a turn;
(2) the abbot's finished sand ('R') is not walkable and is no pivot for a wide-rake U-turn or the rake's start.
Small gardens: C solver vs brute force (counts, turn histograms, every solution valid). Medium gardens: C solver vs
CP-SAT (minimum turns; counts on a subset)."""
import random
import sys
from zg import Garden, brute_force, check_solution
from zcp import optimize, count_solutions
rng = random.Random(int(sys.argv[1]) if len(sys.argv) > 1 else 1)
N_SMALL = int(sys.argv[2]) if len(sys.argv) > 2 else 1500
N_MEDIUM = int(sys.argv[3]) if len(sys.argv) > 3 else 60
def garden(Hr, Wr):
H, W = rng.randint(*Hr), rng.randint(*Wr)
rows = [["." for _ in range(W)] for _ in range(H)]
for _ in range(rng.randint(0, 3)):
rows[rng.randrange(H)][rng.randrange(W)] = rng.choice("##R")
if rng.random() < 0.3: # a stone with some finished sand beside it
r, c = rng.randrange(H), rng.randrange(W)
rows[r][c] = "#"
for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)):
if 0 <= r + dr < H and 0 <= c + dc < W and rng.random() < 0.5:
rows[r + dr][c + dc] = "R"
if rng.random() < 0.2:
sym = rng.choice("hv"); r0, c0 = rng.randrange(H), rng.randrange(W)
for rr in range(r0, min(H, r0 + 2)):
for cc in range(c0, min(W, c0 + 2)):
if rows[rr][cc] == ".":
rows[rr][cc] = sym
border = [(r, c) for r in range(H) for c in range(W) if (r in (0, H - 1) or c in (0, W - 1)) and rows[r][c] in ".hv"]
if len(border) < 2:
return None
gr, gc = rng.choice(border)
sides = [s for s, ok in (("N", gr == 0), ("S", gr == H - 1), ("W", gc == 0), ("E", gc == W - 1)) if ok]
g = Garden(["".join(r) for r in rows], (gr, gc, rng.choice(sides)),
nohairpin=rng.choice([0, 1, 2, 2]), braced_start=rng.random() < 0.4)
if rng.random() < 0.6: # a side gate in another border cell
er, ec = rng.choice([b for b in border if b != (gr, gc)])
g.end = (er, ec)
g.end_side = rng.choice([s for s, ok in (("N", er == 0), ("S", er == H - 1), ("W", ec == 0), ("E", ec == W - 1)) if ok])
if rng.random() < 0.2:
sand = [c for c in g.sand_cells() if c != (gr, gc) and c != g.end]
if sand:
c = rng.choice(sand); g.marks = {c: rng.choice("SB")}
return g
ok = bad = solvable = side = soft = 0
for _ in range(N_SMALL):
g = garden((3, 5), (3, 5))
if g is None or len(g.sand_cells()) > 16:
continue
res = g.solve(mode=0, cap=10**9)
n_bf, hist_bf = brute_force(g)
good = res["nsol"] == n_bf and res["hist"] == hist_bf and all(check_solution(g, s["draw"]) for s in res["sols"])
ok += good; bad += not good
solvable += n_bf > 0; side += g.end_side is not None and n_bf > 0; soft += any("R" in r for r in g.rows) and n_bf > 0
if not good:
print("MISMATCH small", g, res["nsol"], n_bf, res["hist"], hist_bf)
print(f"small (C vs brute force): ok {ok} bad {bad} solvable {solvable} (side gate {side}, finished sand {soft})")
okm = badm = countchecks = 0
for i in range(N_MEDIUM):
g = None
while g is None or not g.sand_cells():
g = garden((5, 6), (6, 7))
best = g.solve(mode=1, cap=10**9, nodelimit=2 * 10**8)
opt = optimize(g, time_limit=60)
c_min = best["best"]["turns"] if "best" in best else None
good = (c_min is None and opt[1] == "INFEASIBLE") or (c_min == opt[0] and opt[1] == "OPTIMAL")
if good: # full counts and histograms where enumeration is cheap
c = g.solve(mode=0, cap=10**9)
if c["nsol"] <= 20000:
n, h, st = count_solutions(g, workers=1)
good = c["nsol"] == n and c["hist"] == h
countchecks += 1
okm += good; badm += not good
if not good:
print("MISMATCH medium", g, c_min, opt[:2])
print(f"medium (C vs CP-SAT): ok {okm} bad {badm} (counts and histograms compared on {countchecks})")
src/verify_doc.py — Independent re-derivation of every number stated in samon.md (C solver for counts; CP-SAT for pars/uniqueness).
"""Independent re-derivation of every number stated in samon.md (C solver for counts; CP-SAT for pars/uniqueness)."""
import json, collections, re
from zg import Garden, turns_of, hairpin_list, check_solution
from zcp import optimize, count_solutions
from levels import from_dict
L = json.load(open('/home/claude/zen/out/levels.json'))
fails = []
def check(name, got, want):
ok = got == want
print(f"{'OK ' if ok else 'BAD'} {name}: got {got} want {want}")
if not ok: fails.append(name)
def med(h):
tot = sum(h.values()); acc = 0
for t in sorted(h):
acc += h[t]
if acc >= tot / 2: return t
# Table 1
T1 = {(3,3): (8,4,2,5,6,2), (4,4): (52,6,4,8,13,4), (5,5): (824,8,10,13,20,4), (6,6): (22144,10,24,20,31,6),
(7,7): (1510446,12,66,27,42,6), (6,8): (899432,11,66,26,42,8)}
for (H, W), want in T1.items():
g = Garden(["."*W]*H, (H-1, 0, "S")); r = g.solve(mode=0, cap=10**12); h = r['hist']
gw = Garden(["."*W]*H, (H-1, 0, "S"), nohairpin=2, braced_start=True); rw = gw.solve(mode=0, cap=10**9)
got = (r['nsol'], min(h), h[min(h)], med(h), max(h), rw['nsol'])
check(f"table1 {H}x{W}", got, want)
if H*W <= 36:
check(f"table1 {H}x{W} CP-SAT par", optimize(g)[0], want[1])
# A145157 9x9 is cited from OEIS (not recomputed: 5.5e10 paths)
# parity
check("parity ok", Garden(["....."]*5, (4,0,"S")).solve(mode=0, cap=10**9)['nsol'], 824)
check("parity bad", Garden(["....."]*5, (4,1,"S")).solve(mode=0, cap=10**9)['nsol'], 0)
# anatomy / first garden
g = from_dict(L['first']['garden']); r = g.solve(mode=0, cap=10**9)
check("first n", r['nsol'], 1023); check("first par", min(r['hist']), 10); check("first at par", r['hist'][10], 2)
check("first CP-SAT par", optimize(g)[0], 10)
# calm garden
g = from_dict(L['calm']['garden']); r = g.solve(mode=0, cap=10**9); h = r['hist']
check("calm n", r['nsol'], 103812); check("calm par", min(h), 17); check("calm at par", h[17], 6)
check("calm median", med(h), 31); check("calm max", max(h), 48); check("calm at max", h[48], 5)
check("calm CP-SAT par", optimize(g)[0], 17)
# ripple
g = from_dict(L['ripple']['garden']); r = g.solve(mode=0, cap=10**9); h = r['hist']
check("ripple n", r['nsol'], 35570); check("ripple par", min(h), 12); check("ripple at par", h[12], 4)
g0 = Garden([x.replace('O', '#') for x in g.rows], g.gate); r0 = g0.solve(mode=0, cap=10**9)
check("ripple plain n", r0['nsol'], 183986); check("ripple plain par", min(r0['hist']), 12); check("ripple plain at par", r0['hist'][12], 5)
d = [tuple(c) for c in L['ripple']['intended']]
check("ripple intended valid", check_solution(g, d), True)
check("ripple start adjacent to stone", any(abs(d[-1][0]-4)<=1 and abs(d[-1][1]-2)<=1 for _ in [0]), True)
# waves
g = from_dict(L['waves']['garden']); r = g.solve(mode=0, cap=10**9); h = r['hist']
check("waves n", r['nsol'], 2984); check("waves par", min(h), 17); check("waves at par", h[17], 34)
g0 = Garden([x.replace('h', '.') for x in g.rows], g.gate); r0 = g0.solve(mode=0, cap=10**9)
check("waves plain n", r0['nsol'], 1492008); check("waves plain par", min(r0['hist']), 16)
# ichimatsu
gi = Garden(["".join("=" if ((rr//3)+(cc//3))%2==0 else "|" for cc in range(9)) for rr in range(9)], (8,0,"S"))
ri = gi.solve(mode=0, cap=100)
check("ichimatsu 9x9 n", ri['nsol'], 2); check("ichimatsu turns", sorted(s['turns'] for s in ri['sols']), [45, 45])
n_cp, _, st = count_solutions(gi, limit=10)
check("ichimatsu CP-SAT count", n_cp, 2)
g6 = ["===|||"]*3 + ["|||==="]*3
uniq = [gc for gc in range(6) if Garden(g6, (5, gc, "S")).solve(mode=0, cap=5)['nsol'] == 1]
check("ichimatsu 6x6 unique gate cols", uniq, [0, 1, 2, 3, 5])
# grooves
g = from_dict(L['grooves']['garden'])
check("grooves count", len(g.clues), 5)
check("grooves unique", g.solve(mode=0, cap=5)['nsol'], 1)
check("grooves CP-SAT unique", count_solutions(g, limit=5)[0], 1)
g0 = Garden(g.rows, g.gate); r0 = g0.solve(mode=0, cap=10**9)
check("grooves plain n", r0['nsol'], 12375); check("grooves plain par", min(r0['hist']), 21); check("grooves plain at par", r0['hist'][21], 11)
d = [tuple(c) for c in L['grooves']['intended']]
check("grooves answer turns", turns_of(g, d), 21)
# wide rake
w = json.load(open('/home/claude/zen/out/wide_pick.json'))
g = from_dict(w['garden'])
check("wide unique (C)", g.solve(mode=0, cap=5)['nsol'], 1)
check("wide unique (CP-SAT)", count_solutions(g, limit=5)[0], 1)
d = [tuple(c) for c in w['draw']]
check("wide answer turns", turns_of(g, d), 17)
gp = Garden(g.rows, g.gate); rp = gp.solve(mode=0, cap=10**9)
check("wide plain n", rp['nsol'], 6310226); check("wide plain par", min(rp['hist']), 17); check("wide plain at par", rp['hist'][17], 12)
pr = gp.solve(mode=2, bound=17, cap=100)
graceful = [s for s in pr['sols'] if not any(not br for *_, br in hairpin_list(gp, s['draw']))]
check("wide: graceful par rakings", len(graceful), 1)
check("wide: graceful par == wide answer", graceful[0]['draw'] == d, True)
# two gates
t = json.load(open('/home/claude/zen/out/two_pick.json'))
g = from_dict(t['garden'])
check("two-gate wide unique", g.solve(mode=0, cap=5)['nsol'], 1)
check("two-gate CP-SAT unique", count_solutions(g, limit=5)[0], 1)
d = [tuple(c) for c in t['draw']]; check("two-gate answer turns (the bend entering through the side gate counts)", turns_of(g, d), 20)
gp = Garden(g.rows, g.gate, end=g.end, end_side=g.end_side); rp = gp.solve(mode=0, cap=10**9)
check("two-gate plain n", rp['nsol'], 311080); check("two-gate plain par", min(rp['hist']), 20); check("two-gate plain at par", rp['hist'][20], 32)
check("two-gate plain par by CP-SAT", optimize(gp, time_limit=120)[:2], (20, 'OPTIMAL'))
# the abbot's pebbles: the example garden
from grader import grade
mp = json.load(open('/home/claude/zen/out/marks_pick.json'))
gm = from_dict(mp['garden']); dm = [tuple(c) for c in mp['draw']]
gro = {tuple(map(int, k.split(','))): v for k, v in mp['grooves'].items()}
gg = Garden(gm.rows, gm.gate, clues=gro); gp = Garden(gm.rows, gm.gate)
rp = gp.solve(mode=0, cap=10**8)
check("pebbles garden plain n", rp['nsol'], 59498); check("pebbles garden par", min(rp['hist']), 13); check("pebbles garden at par", rp['hist'][13], 6)
check("pebbles count", len(gm.marks), 8); check("pebbles unique (C)", gm.solve(mode=0, cap=5)['nsol'], 1)
check("pebbles unique (CP-SAT)", count_solutions(gm, limit=5)[0], 1)
check("pebbles answer turns", turns_of(gm, dm), 13); check("pebbles answer valid", check_solution(gm, dm), True)
check("grooves version count", len(gro), 6); check("grooves version unique", gg.solve(mode=0, cap=5)['nsol'], 1)
gr1, gr2 = grade(gm), grade(gg)
check("pebbles tier/trials", (gr1['tier'], gr1['trials']), (3, 14)); check("grooves version tier", gr2['tier'], 1)
# pebbles vs grooves experiment: re-check every saved garden's uniqueness and grade, then the statistics
import statistics
rows = json.load(open('/home/claude/zen/out/marks_vs_grooves.json'))
check("experiment gardens", len(rows), 88)
uniq_ok, gts, mts = True, [], []
for r in rows:
base = dict(rows=r['rows'], gate=r['gate'], clues={}, end=None, nohairpin=0, braced_start=False, marks={})
g1 = from_dict({**base, 'clues': r['grooves']}); g2 = from_dict({**base, 'marks': r['marks']})
tgt = [tuple(c) for c in r['target']]
for gx in (g1, g2):
rr = gx.solve(mode=0, cap=3, stream=True)
uniq_ok &= rr['nsol'] == 1 and [tuple(c) for c in rr['sols'][0]['draw']] == tgt
a, b = grade(g1), grade(g2)
gts.append((a['tier'], a['trials'])); mts.append((b['tier'], b['trials']))
check("experiment: every garden unique both ways, with the intended answer", uniq_ok, True)
check("experiment: recomputed grades match saved", [(r['g_tier'], r['g_trials']) for r in rows] == gts and [(r['m_tier'], r['m_trials']) for r in rows] == mts, True)
ng, nm = [len(r['grooves']) for r in rows], [len(r['marks']) for r in rows]
check("median grooves / pebbles", (statistics.median(ng), statistics.median(nm)), (7, 11))
check("share of gardens needing more pebbles than grooves (%)", round(100 * sum(b > a for a, b in zip(ng, nm)) / 88), 92)
check("lookahead share grooves / pebbles (%)", (round(100 * sum(t >= 3 for t, _ in gts) / 88), round(100 * sum(t >= 3 for t, _ in mts) / 88)), (47, 92))
check("nested lookahead grooves / pebbles (%)", (sum(t >= 4 for t, _ in gts), round(100 * sum(t >= 4 for t, _ in mts) / 88, 1)), (0, 12.5))
check("median lookahead steps grooves / pebbles", (statistics.median([n for t, n in gts if t >= 3]), statistics.median([n for t, n in mts if t >= 3])), (2, 7))
# prototype: the full campaign (each garden's par, raking count, par rakings and hint are re-derived, with asserts, by export_proto.py)
P = json.load(open('/home/claude/zen/out/proto_gardens.json'))
check("prototype gardens", len(P), 148)
check("gardens per world", [sum(p['world'] == w for p in P) for w in range(1, 8)], [19, 20, 20, 25, 26, 18, 20])
check("main path / extras per world", ([sum(p['world'] == w and not p['extra'] for p in P) for w in range(1, 8)],
[sum(p['world'] == w and p['extra'] for p in P) for w in range(1, 8)]), ([9, 10, 11, 16, 9, 9, 8], [10, 10, 9, 9, 17, 9, 12]))
check("main path / extras totals", (sum(not p['extra'] for p in P), sum(p['extra'] for p in P)), (72, 76))
check("the first five gardens", [(p['name'], p['role']) for p in P[:5]], [('The corridor', 'intro'), ('Nine squares', 'intro'), ('Morning dew', 'application'),
('The pocket', 'intro'), ('Two tones', 'intro')])
check("the third garden is the first generated one, 5x5 with one stone", (sum(ch == '#' for r in P[2]['rows'] for ch in r) > 0, len(P[2]['rows']), len(P[2]['rows'][0])), (True, 5, 5))
check("the 2x3 garden is gone", any(p['name'] == 'Two rows' for p in P), False)
import math
need = [math.ceil(2 / 3 * sum(p['world'] == w and not p['extra'] for p in P)) for w in range(1, 7)]
check("gardens raked before grooves (world 4) / the wide rake (world 5) open", (sum(need[:3]), sum(need[:4])), (21, 32))
old_need = [math.ceil(2 / 3 * n) for n in (20, 18, 18, 20)] # the first campaign: every garden on the path
check("first campaign: grooves / wide rake after (Astra's count)", (sum(old_need[:3]), sum(old_need)), (38, 52))
check("demonstrations", [p['name'] for p in P if p['demo']], ['The corridor', 'Nine squares', 'First ripple', 'First wave', 'Ichimatsu',
'First grooves', 'The finished ring', 'His pebbles', 'Wide blade', 'Two gates'])
check("the corridor's par (quoted in the demonstration)", next(p['par'] for p in P if p['name'] == 'The corridor'), 3)
check("no demonstration among the extras", any(p['demo'] and p['extra'] for p in P), False)
check("every garden has a first hint", all(isinstance(p['hint'], str) and len(p['hint']) > 10 for p in P), True)
check("every garden's par rakings stored", all(len(p['par_moves']) == p['at_par'] for p in P), True)
CS = json.load(open('/home/claude/zen/out/campaign_stats.json'))
specs = {k: v for k, v in CS.items() if isinstance(v, dict) and 'found' in v}
check("campaign: generation specs (47, and one fallback)", len(specs), 48)
check("campaign: 9x9 pebble gardens found / layouts tried / minutes", (specs['w7-p99']['found'], specs['w7-p99']['tried'], round(specs['w7-p99']['seconds'] / 60)), (0, 227, 16))
check("campaign: 8x8 pebble fallback found enough", specs['w7-p88']['found'] >= 2 and specs['w7-p88'].get('fallback_for') == 'w7-p99', True)
import glob, pickle
cand_keys = set()
for f in glob.glob('/home/claude/zen/out/campaign_cache/w*.pkl'):
for c in pickle.load(open(f, 'rb'))[1]:
cand_keys.add((tuple(c['garden'].rows), tuple(c['garden'].gate), tuple(c['garden'].end) if c['garden'].end else None))
C = json.load(open('/home/claude/zen/out/campaign.json'))
ngen = sum((tuple(e['garden']['rows']), tuple(e['garden']['gate']), tuple(e['garden']['end']) if e['garden']['end'] else None) in cand_keys for e in C)
check("campaign: generated / teaching gardens / gardens in contrasting pairs", (ngen, len(C) - ngen, sum(1 for p in P if p['pair'])), (114, 34, 13))
check("campaign: exported in the same order", [e['name'] for e in C] == [p['name'] for p in P], True)
from campaign import canon
same_layout = collections.Counter(canon(from_dict(e['garden'])) for e in C)
twins = sorted(sorted(e['name'] for e in C if canon(from_dict(e['garden'])) == k) for k, n in same_layout.items() if n > 1)
check("campaign: no two gardens alike up to rotation and reflection, but the pairs that share a layout", twins,
[['First grooves', 'Open ground'], ['His grooves', 'His pebbles'], ['Narrow rake', 'Wide blade']])
check("campaign: candidates / layouts sampled / minutes of computing", (sum(v['found'] for v in specs.values()), sum(v['tried'] for v in specs.values()), round(sum(v['seconds'] for v in specs.values()) / 60)), (235, 16437, 33))
check("campaign: about half the time on 9x9 pebbles", round(specs['w7-p99']['seconds'] / sum(v['seconds'] for v in specs.values()), 1), 0.5)
check("campaign: world 7 pebble gardens are 8x8", sorted({(len(p['rows']), len(p['rows'][0])) for p in P if p['world'] == 7 and p['marks']}), [(8, 8)])
check("campaign: hints with only generic advice", sum('Par is' in p['hint'] for p in P), 0)
check("campaign: template roles", sorted(collections.Counter(p['role'] for p in P).items()),
[('application', 93), ('calm', 7), ('capstone', 7), ('combination', 15), ('intro', 13), ('pair', 6), ('trap', 7)])
mains = {w: [p['role'] for p in P if p['world'] == w and not p['extra']] for w in range(1, 8)}
check("campaign: each world's main path opens with its introduction (a pair, or for world 7 an intro), has one trap, "
"and ends calm, capstone", all(m[0] in ('pair', 'intro') and 'intro' in m and m.count('trap') == 1 and m[-2:] == ['calm', 'capstone']
for m in mains.values()), True)
check("campaign: extras come after each world's main path", all(
[p['extra'] for p in P if p['world'] == w] == sorted(p['extra'] for p in P if p['world'] == w) for w in range(1, 8)), True)
ft = [p['trap'] for p in P if p['role'] == 'trap' and p['world'] <= 3]
check("campaign: free traps: share of near-misses that miss the key observation (min, max)", (round(min(ft), 2), round(max(ft), 2)), (0.74, 0.94))
HS = json.load(open('/home/claude/zen/out/hint_stats.json'))
hf = [h for h in HS if h['kind'] == 'free']; hs = [h for h in HS if h['kind'] == 'strict' and not h.get('trap')]
check("hints: free gardens, median share of near-misses that get the hint wrong", (len(hf), round(statistics.median(h['miss'] for h in hf), 2)), (50, 0.5))
tr = collections.Counter((h['tier'], h['tier_after']) for h in hs)
check("hints: strict gardens; need lookahead; then none / nested->one level / fewer steps", (len(hs), sum(h['tier'] >= 3 for h in hs), tr[(3, 1)] + tr[(3, 2)], tr[(4, 3)],
sum(h['tier'] == h['tier_after'] == 3 and h['trials_after'] < h['trials'] for h in hs)), (80, 62, 16, 10, 36))
check("hints: no strict hint leaves lookahead as it was", sum(h['tier'] == h['tier_after'] and h['tier'] >= 3 and h['trials_after'] >= h['trials'] for h in hs), 0)
check("hints: strict gardens without lookahead; made local / already local", (tr[(2, 1)], tr[(1, 1)]), (4, 14))
check("hints: end hints that say the cell has one way in", sorted(p['name'] for p in P if 'only one way in, so the rake' in p['hint']),
['First grooves', 'His grooves', 'His pebbles', 'Interrupted', 'The finished ring']) # (names follow layouts: out/garden_names.json)
check("campaign: teaching gardens include 13 of the original 14", {p['name'] for p in P} >= {"Nine squares", "The pocket", "First stroke", "Calm", "Ripples", "Waves", "Ichimatsu", "The master's grooves", "The abbot's pebbles", "Wide rake", "Spiral", "Side gate", "Silver sand"}, True)
# the abbot's taste vs the wide rake (exp_continuity.py): par rakings with a U-turn in open sand, free gardens of worlds 1-3
CT = json.load(open('/home/claude/zen/out/continuity.json'))
check("continuity: free gardens; all par rakings graceful; mixed; none graceful",
(CT['free_gardens'], CT['all_graceful'], CT['mixed'], CT['none_graceful']), (54, 11, 42, 1))
check("continuity: par rakings; with an open-sand U-turn", (CT['par_rakings'], CT['par_rakings_with_open_u']), (407, 327))
wv = next(r for r in CT['gardens'] if r['name'] == 'Waves')
check("continuity: Waves' par rakings, and how many turn back in open sand", (wv['at_par'], wv['with_open_u']), (34, 32))
check("continuity: the one free garden with no graceful par raking is a hand-made one", [r['name'] for r in CT['gardens'] if r['with_open_u'] == r['at_par']], ['The pocket'])
# the contrasting pairs (pairs.py), re-checked from the exported gardens
PP = {p['name']: p for p in P}
from zg import check_solution as _cs
def g_of(p):
marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()}
clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()}
return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None,
nohairpin=2 if p['wide'] else 0, braced_start=p['wide'], end_side=p['end_side'])
STEP = {"N": (-1, 0), "S": (1, 0), "E": (0, 1), "W": (0, -1)}
def cells_of(p, ms):
r, c = p['gate'][0], p['gate'][1]; out = [(r, c)]
for ch in ms:
r, c = r + STEP[ch][0], c + STEP[ch][1]; out.append((r, c))
return out
pair_names = [("Plain stone", "First ripple"), ("Calm water", "First wave"), ("Open ground", "First grooves"), ("His grooves", "His pebbles"),
("Narrow rake", "Wide blade"), ("One gate", "Two gates")]
check("pairs: sand cells of every pair garden (and the finished ring's)", sorted({sum(ch in '.hv=|' for r in PP[n]['rows'] for ch in r)
for ab in pair_names + [("The finished ring",)] for n in ab}), [17, 18, 21, 22, 23])
for a, b in [("Plain stone", "First ripple"), ("Calm water", "First wave")]:
check(f"pairs: every par raking of {a} breaks the rule added in {b}", any(_cs(g_of(PP[b]), cells_of(PP[a], ms)) for ms in PP[a]['par_moves']), False)
check("pairs: every par raking of Narrow rake turns back in open sand", all(any(not br for *_, br in hairpin_list(g_of(PP['Narrow rake']), cells_of(PP['Narrow rake'], ms)))
for ms in PP['Narrow rake']['par_moves']), True)
check("pairs: Open ground's rakings; First grooves' grooves and rakings", (PP['Open ground']['rakings'], len(PP['First grooves']['clues']), PP['First grooves']['rakings']), (8, 1, 1))
check("pairs: His grooves' grooves; His pebbles' pebbles (pale, dark), at the grooves' cells; rakings",
(len(PP['His grooves']['clues']), sorted(PP['His pebbles']['marks'].values()), set(PP['His pebbles']['marks']) == set(PP['His grooves']['clues']),
PP['His pebbles']['rakings']), (2, ['B', 'S'], True, 1))
og, tg = PP['One gate'], PP['Two gates']
check("pairs: One gate has two wide-rake rakings; Two gates one, which is not One gate's par raking",
(og['rakings'], tg['rakings'], turns_of(g_of(og), cells_of(tg, tg['par_moves'][0])) > og['par']), (2, 1, True))
check("pairs: which gardens of the wide and side pairs use the wide rake (Narrow rake, Wide blade, One gate, Two gates)", [PP[n]['wide'] for n in ("Narrow rake", "Wide blade", "One gate", "Two gates")], [False, True, True, True])
check("first campaign's big introductions, now applications: Waves; The master's grooves; The abbot's pebbles (sand cells)",
[sum(ch in '.hv=|' for r in PP[n]['rows'] for ch in r) for n in ("Waves", "The master's grooves", "The abbot's pebbles")], [55, 47, 47])
check("side-gate gardens", sum(1 for p in P if p['end']), 19)
small = solv = side = soft = medium = 0
for f in ('/home/claude/zen/out/test_side_pivot_seed1.log', '/home/claude/zen/out/test_side_pivot_seed2.log'):
t = open(f).read()
a = re.search(r'ok (\d+) bad (\d+) solvable (\d+) \(side gate (\d+), finished sand (\d+)\)', t)
b = re.search(r'medium \(C vs CP-SAT\): ok (\d+) bad (\d+) \(counts and histograms compared on (\d+)\)', t)
check(f"side/pivot cross-check log {f[-9:]}: no disagreements", (a.group(2), b.group(2)), ('0', '0'))
small += int(a.group(1)); solv += int(a.group(3)); side += int(a.group(4)); soft += int(a.group(5)); medium += int(b.group(3))
check("side/pivot cross-check: small gardens; solvable; with a side gate; with finished sand; medium (counts compared)", (small, solv, side, soft, medium), (3192, 411, 132, 149, 120))
# garden-like stones vs single-cell stones (exp_sampler.py, 1,000 random 7x7 layouts each)
Y = json.load(open('/home/claude/zen/out/sampler_yields.json'))['summary']
check("sampler layouts", (Y['garden']['layouts'], Y['single']['layouts']), (1000, 1000))
check("sampler stone cells", (round(Y['garden']['mean_stone_cells'], 1), round(Y['single']['mean_stone_cells'], 1)), (4.7, 2.0))
check("sampler solvable %", (round(100 * Y['garden']['solvable']), round(100 * Y['single']['solvable'])), (28, 39))
check("sampler usable free %", (round(100 * Y['garden']['graceful_free'], 1), round(100 * Y['single']['graceful_free'], 1)), (4.7, 3.8))
check("sampler wide-unique %", (round(100 * Y['garden']['wide_unique'], 1), round(100 * Y['single']['wide_unique'], 1)), (2.2, 2.7))
check("sampler side-unique %", (round(100 * Y['garden']['side_unique'], 1), round(100 * Y['single']['side_unique'], 1)), (1.8, 1.3))
check("sampler grooves median / lookahead %", (Y['garden']['median_grooves'], Y['single']['median_grooves'], round(100 * Y['garden']['grooves_lookahead']), round(100 * Y['single']['grooves_lookahead'])), (6, 6, 28, 84))
H = json.load(open('/home/claude/zen/out/hint_check.json'))
check("hint check: routes, disagreements, timeouts", (H['routes'], H['bad'], H['states']['unknown']), (4440, 0, 0))
PG = {p['name']: p for p in P}
check("hint check: no search ran out of budget", H['unknown_in'], [])
check("hint check: stuck routes; rewinds proven nearest / unproven", (H['states']['stuck'], H['proven_rewinds'], H['unproven_rewinds']), (2011, 1989, 22))
check("hint check: the unproven rewinds are in three of the largest gardens (8x8 or 9x9)", sorted({(len(PG[n]['rows']), len(PG[n]['rows'][0])) for n in H['unproven_in']}), [(8, 8), (9, 9)])
check("hint check: unproven rewinds are in three gardens", len(set(H['unproven_in'])), 3)
check("hint check: slowest analysis 0.7 s", round(H['max_ms'] / 1000, 1), 0.7)
import re
small = medium = 0
for f in ('/home/claude/zen/out/test_marks_seed1.log', '/home/claude/zen/out/test_marks_seed2.log'):
t = open(f).read()
a = re.search(r'ok (\d+) bad (\d+) solvable', t); b = re.search(r'medium \(C vs CP-SAT\): ok (\d+) bad (\d+)', t)
check(f"marks cross-check log {f[-9:]}: no disagreements", (a.group(2), b.group(2)), ('0', '0'))
small += int(a.group(1)); medium += int(b.group(1))
check("pebbles cross-check: small / medium gardens", (small, medium), (6000, 108))
tot = 0
for f in ('/home/claude/zen/out/test_grader_marks_seed7.log', '/home/claude/zen/out/test_grader_marks_seed11.log'):
m = re.search(r'ok (\d+) bad (\d+)', open(f).read())
check(f"grader soundness log {f[-10:]}: no failures", m.group(2), '0'); tot += int(m.group(1))
print("grader soundness gardens:", tot)
# poems (Presentation; Credits)
PO = json.load(open('/home/claude/zen/poems/samon-poems.json'))
AS = json.load(open('/home/claude/zen/poems/astra-samon-poem-selection.json'))
pp = PO['poems']; used = list(PO['gardens'].values()) + [PO['practice']]
DOC = open('/home/claude/zen/samon.md').read()
check("poems: one for each garden and one for the practice garden, all different", (len(PO['gardens']), len(set(used)), set(PO['gardens']) == {p['name'] for p in P}), (148, 149, True))
check("poems: every text re-checked against a page that prints it", sum(pp[i]['reverified'].startswith('yes') and pp[i]['source_url'].startswith('https://') for i in used), 149)
pc = collections.Counter(pp[i]['poet'] for i in used)
check("poems: poets; Bashō, Teika, Buson, Rengetsu", (len(pc), pc['Matsuo Bashō'], pc['Fujiwara no Teika'], pc['Yosa Buson'], pc['Ōtagaki Rengetsu']), (25, 34, 23, 20, 13))
check("poems: Tsurayuki, Izumi Shikibu and Shiki have one each", (pc['Ki no Tsurayuki'], pc['Izumi Shikibu'], pc['Masaoka Shiki']), (1, 1, 1))
check("poems: doc states the counts", all(s in DOC for s in ("149 (one for each garden, and one for the practice garden) by 25 poets", "Bashō (34), Teika (23), Buson (20) and Rengetsu (13)")), True)
anames = {l['name']: l['poem_id'] for l in AS['levels']}
check("poems: Astra's selection covered 136 gardens", len(AS['levels']), 136)
rev = [r for r in PO['about']['revisions'] if 'to' in r]
check("poems: second and third passes: 25 swaps, each in force, to a poem used by that garden alone (Owl's hour took the capstone's)", (len(rev), all(PO['gardens'][r['garden']] == r['to'] for r in rev), len({r['to'] for r in rev}), sum('translation_repair' in r for r in PO['about']['revisions'])), (25, True, 25, 4))
check("poems: new gardens paired / poems new to the selection / pairings changed since Astra",
(sum(g not in anames for g in PO['gardens']), sum(i not in AS['poems'] for i in used), sum(g in anames and anames[g] != PO['gardens'][g] for g in PO['gardens'])), (13, 23, 23))
check("poems: doc says 25 pairings were replaced in two further passes", "replaced 25 pairings judged weak" in DOC, True)
check("poems: translations revised / new", (sum('revised' in pp[i]['translation_credit'] for i in used), sum('New translation' in pp[i]['translation_credit'] for i in used)), (25, 17))
check("poems: doc states translations revised (first pass)", "revised 22 of the translations" in DOC, True)
check("poems: all written before 1900 (poets' death years or floruit; Shiki's by its year), and one poet who outlived it", (max(min(int(re.findall(r'\d{3,4}', pp[i]['author_dates'])[-1]), pp[i].get('composed', 9999)) for i in used) < 1900, sum(int(re.findall(r'\d{3,4}', pp[i]['author_dates'])[-1]) >= 1900 for i in used)), (True, 1))
G1 = PO['gardens']
check("poems: first garden = sweeping the temple garden; new pond then old pond at the first ripple stone; wave pair = the two Kiyotaki drafts; last main-path garden = the sand at Kehi",
(pp[G1['The corridor']]['japanese'], pp[G1['Plain stone']]['japanese'], pp[G1['First ripple']]['japanese'], pp[G1['Calm water']]['japanese'], pp[G1['First wave']]['japanese'], pp[G1['Silver sand']]['japanese'],
[p['name'] for p in P if p['world'] == 7 and not p['extra']][-1], P[0]['name'], PG['First ripple']['role'], PG['First wave']['pair'], PG['Calm water']['pair'], PG['Plain stone']['pair']),
('庭掃て出ばや寺に散柳', '新池や蛙とびこむ音もなし', '古池や蛙飛びこむ水の音', '清滝や波に塵なき夏の月', '清滝や波に散り込む青松葉', '月清し遊行のもてる砂の上', 'Silver sand', 'The corridor', 'intro', 'band', 'band', 'ripple'))
check("poems: the first and last poems quoted as translated", all(pp[G1[g]]['translation'].replace('\n', ' / ') in DOC for g in ('The corridor', 'Silver sand')), True)
# the daily garden (Progression; Prototype)
DY = json.load(open('/home/claude/zen/out/daily_gardens.json'))
DS = json.load(open('/home/claude/zen/out/daily_stats.json'))
from campaign import canon as canon_
camp_canon = {canon_(from_dict(e['garden'])) for e in C}
def garden_of_entry(e):
return from_dict({"rows": e["rows"], "gate": e["gate"], "clues": e["clues"], "end": e["end"], "nohairpin": 2 if e["wide"] else 0,
"braced_start": bool(e["wide"]), "end_side": e["end_side"], "marks": e["marks"], "name": e["name"]})
check("daily: a year of gardens, even days free and odd days strict", (len(DY), all((e['rakings'] > 1) == (k % 2 == 0) for k, e in enumerate(DY))), (366, True))
check("daily: none shares a layout with the campaign, and no two share one", (sum(canon_(garden_of_entry(e)) in camp_canon for e in DY), len({canon_(garden_of_entry(e)) for e in DY})), (0, 366))
check("daily: sizes 5x5 to 8x8", (min(len(e['rows']) * len(e['rows'][0]) for e in DY), max(len(e['rows']) for e in DY), max(len(e['rows'][0]) for e in DY)), (25, 8, 8))
check("daily: every mechanic appears", all(DS[k] > 0 for k in ('with_ripple', 'with_band', 'with_grooves', 'with_pebbles', 'wide', 'side_gate', 'closed_ring')), True)
check("daily: generation took about 8 minutes on two cores", round(sum(v['seconds'] for v in DS['specs'].values()) / 60 / 2), 8)
check("daily: doc states the year and the timing", all(x in DOC for x in ("a year of 366 gardens", "took 8 minutes of generation on two cores")), True)
import random as random_
smp = random_.Random(7).sample(range(366), 12)
okd = []
for k in smp: # a sample re-derived: the exact count, CP-SAT par, the intended raking at par
e = DY[k]; g = garden_of_entry(e); r = g.solve(mode=0, cap=10**9); d = [tuple(c) for c in e['intended']]
okd.append(r['nsol'] == e['rakings'] and optimize(g)[0] == e['par'] and check_solution(g, d) and turns_of(g, d) == e['par'] and r['hist'].get(e['par']) == e['at_par'] == len(e['par_moves']))
check("daily: a sample of 12 re-derived (count, par, intended raking, par rakings)", all(okd), True)
# the sound (Presentation; Prototype): the recording carried in the page, as the document describes it
import base64 as base64_, subprocess as subprocess_
SP = json.load(open('/home/claude/zen/out/sound_pieces.json'))
ST = json.load(open('/home/claude/zen/sound/tracks.json'))
check("sound: one recording, Shika no Tōne, credited to Araki Kodō III with its Commons source, public domain",
(len(SP), SP[0]['title'][:13], 'Araki Kodō III' in SP[0]['player'], SP[0]['source'], SP[0]['license'].startswith('public domain'), SP[0]['title'] == ST['tracks'][0]['title']),
(1, 'Shika no Tōne', True, 'https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg', True, True))
mp3 = base64_.b64decode(SP[0]['b64'])
open('/tmp/claude-0/verify_piece.mp3', 'wb').write(mp3) # (probed from a file: piped input hides the duration and bit rate)
pr = json.loads(subprocess_.run(['ffprobe', '-hide_banner', '-loglevel', 'error', '-show_entries', 'format=duration,bit_rate:stream=codec_name,channels', '-of', 'json', '/tmp/claude-0/verify_piece.mp3'], capture_output=True, check=True).stdout)
check("sound: mono MP3, 6 min 15 s, 32 kbps, 1.5 MB, as stated", (pr['streams'][0]['codec_name'], pr['streams'][0]['channels'], round(float(pr['format']['duration'])), round(int(pr['format']['bit_rate']) / 1000), round(len(mp3) / 1e6, 1)), ('mp3', 1, 375, 32, 1.5))
check("sound: doc states the encoding, the source and the credit", all(x in DOC for x in ("re-encoded as mono MP3 at 32 kbps (1.5 MB)", "File:Shikanotoone_new.ogg", "Araki Kodō III (1879--1935) on Victor 13029")), True)
check("sound: the page carries the recording and its credits", all(x in open('/home/claude/zen/proto/samon.html').read() for x in ('class="piece"', 'data-title="Shika no Tōne', 'data:audio/mpeg;base64,' + SP[0]['b64'][:64])), True)
print("FAILS:", fails)
src/xc_cpsat.py
import random, sys, time
from zg import Garden
from zcp import count_solutions, optimize
cases = [
Garden([".....", ".....", ".....", ".....", "....."], (4, 0, "W")),
Garden(["......", "..#...", "......", "...#..", "......"], (4, 0, "S")),
Garden(["......", ".O....", "......", "....#.", "......"], (0, 5, "E")),
Garden(["......", "..hhh.", "..hhh.", "......", "......"], (4, 2, "S")),
Garden(["......", ".O....", "......", "....#.", "......"], (0, 5, "E"), nohairpin=True),
Garden(["......", "..#...", "......", "...#..", "......", "......"], (5, 0, "W"), nohairpin=True),
Garden(["......", "......", "......", "......"], (3, 0, "W"), clues={(1, 2): "NS", (2, 4): "EW"}),
Garden(["......", "......", "......", "......"], (3, 0, "W"), end=(0, 5)),
]
for g in cases:
t0 = time.time(); c = g.solve(mode=0, cap=10**9); t1 = time.time()
n, h, st = count_solutions(g, workers=1); t2 = time.time()
b = g.solve(mode=1, cap=10**9)
opt = optimize(g)
print(f"C={c['nsol']} ({t1-t0:.2f}s) CPSAT={n} {st} ({t2-t1:.1f}s) hist_eq={c['hist']==h} Cmin={b['best']['turns'] if 'best' in b else None} CPmin={opt[0]} {opt[1]}")
src/xc_cpsat2.py
import time
from zg import Garden
from zcp import count_solutions, optimize
cases = [
Garden(["......", "......", "......", "......", "......"], (4, 0, "W"), nohairpin=2),
Garden(["......", "..#...", "......", "...#..", "......"], (4, 0, "S"), nohairpin=2),
Garden(["......", "......", "......", "......", "......"], (4, 0, "W"), braced_turns=True),
Garden([".......", "..#....", ".......", "....#..", ".......", "......."], (5, 0, "W"), braced_turns=True),
Garden(["......", ".O....", "......", "....#.", "......"], (0, 5, "E"), nohairpin=2),
Garden([".......", ".......", "..#....", ".......", "....#..", "......."], (5, 0, "W"), nohairpin=2, braced_turns=True),
]
for g in cases:
t0 = time.time(); c = g.solve(mode=0, cap=10**9); t1 = time.time()
n, h, st = count_solutions(g, workers=1); t2 = time.time()
b = g.solve(mode=1, cap=10**9)
opt = optimize(g)
print(f"C={c['nsol']} ({t1-t0:.2f}s) CPSAT={n} {st} ({t2-t1:.1f}s) hist_eq={c['hist']==h} Cmin={b['best']['turns'] if 'best' in b else None} CPmin={opt[0]} {opt[1]}")
src/xc_stats.py
import sys, random, time
sys.path.insert(0, '/home/claude/zen/src')
from zg import Garden, brute_force, check_solution
src = open('/home/claude/zen/src/test_crosscheck.py').read().split('ok = bad = 0')[0]
ns = {}
exec(src.replace('random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 1)', f'random.seed({sys.argv[1]})'), ns)
rg = ns['random_garden']
tot = nz = bad = 0; maxsol = 0
featcount = {'ring':0,'zone':0,'clue':0,'nohp':0,'end':0}
t0 = time.time()
while time.time() - t0 < float(sys.argv[2]):
g = rg()
if g is None or len(g.sand_cells()) > 18: continue
try: g.ring_sets()
except ValueError: continue
res = g.solve(mode=0, cap=10**9)
n, h = brute_force(g)
if res['nsol'] != n or res['hist'] != h or not all(check_solution(g, s['draw']) for s in res['sols']):
bad += 1; print('BAD', g, res['nsol'], n, flush=True)
tot += 1
if n:
nz += 1; maxsol = max(maxsol, n)
if g.ring_sets(): featcount['ring'] += 1
if any(ch in 'hv' for row in g.rows for ch in row): featcount['zone'] += 1
if g.clues: featcount['clue'] += 1
if g.nohairpin: featcount['nohp'] += 1
if g.end: featcount['end'] += 1
print('total', tot, 'solvable', nz, 'bad', bad, 'max sols', maxsol, featcount, flush=True)
src/zcp.py — CP-SAT model of the one-stroke raking puzzle (independent of the C solver).
"""zcp.py: CP-SAT model of the one-stroke raking puzzle (independent of the C solver).
Used for (1) cross-validating solution counts, (2) proving 'par' (minimum turns), and
(3) multi-objective trade-offs (turns vs hairpins).
Arcs are in *raking* order: the stroke runs from the rake-start P to the gate cell, then exits.
"""
from __future__ import annotations
from ortools.sat.python import cp_model
from zg import Garden, DIRS, DR, DC, OPP
PERP = {"N": "EW", "S": "EW", "E": "NS", "W": "NS"}
class Model:
def __init__(self, g: Garden, nohairpin=None):
self.g = g
m = self.m = cp_model.CpModel()
cells = g.sand_cells()
self.cells = cells
idx = self.idx = {c: i for i, c in enumerate(cells)}
n = len(cells)
D = n
gate = (g.gate[0], g.gate[1])
arcs = []
self.x = {} # (cell, d) -> literal for rake moving from cell in direction d
for c in cells:
for d in DIRS:
nb = (c[0] + DR[d], c[1] + DC[d])
if nb in idx:
lit = m.NewBoolVar(f"x{c}{d}")
self.x[(c, d)] = lit
arcs.append((idx[c], idx[nb], lit))
self.start = {}
for c in cells:
if g.end is not None and c != tuple(g.end):
continue
if g.braced_start and not any(g.pivot(c[0] + DR[d], c[1] + DC[d]) for d in DIRS):
continue
lit = m.NewBoolVar(f"s{c}")
self.start[c] = lit
arcs.append((D, idx[c], lit))
exit_lit = m.NewConstant(1)
arcs.append((idx[gate], D, exit_lit))
m.AddCircuit(arcs)
# AddCircuit only covers nodes that have arcs: a sand cell with none (walled in by stones and finished sand)
# must still make the garden infeasible, so every cell needs exactly one way in (an arc, or being the start)
into = {i: [] for i in range(n)}
for a, b, lit in arcs:
if b < n:
into[b].append(lit)
for i in range(n):
m.Add(sum(into[i]) == 1)
false = m.NewConstant(0)
# direction literals
self.ind = {}
self.outd = {}
for c in cells:
for d in DIRS:
prev = (c[0] - DR[d], c[1] - DC[d])
self.ind[(c, d)] = self.x.get((prev, d), false)
self.outd[(c, d)] = self.x.get((c, d), false)
if c == gate:
self.outd[(c, g.gate[2])] = exit_lit
# straightness indicators (with a side gate, the stroke enters its cell through the wall: a bend there is a turn)
side_in = None
if g.end is not None and g.end_side:
side_in = (tuple(g.end), OPP[g.end_side])
one = m.NewConstant(1)
self.straight = {}
for c in cells:
for d in DIRS:
a, b = (one if (c, d) == side_in else self.ind[(c, d)]), self.outd[(c, d)]
s = m.NewBoolVar(f"st{c}{d}")
m.AddImplication(s, a)
m.AddImplication(s, b)
m.AddBoolOr([a.Not(), b.Not(), s])
self.straight[(c, d)] = s
self.turns = m.NewIntVar(0, n, "turns")
m.Add(self.turns == (n if side_in else n - 1) - sum(self.straight.values()))
# hairpins: rake moves a into u, b from u to v, then -a out of v
self.hp = []
self.hp_braced = []
for (u, b), lit in self.x.items():
v = (u[0] + DR[b], u[1] + DC[b])
for a in PERP[b]:
self.hp_braced.append(g.pivot(u[0] + DR[a], u[1] + DC[a]) and g.pivot(v[0] + DR[a], v[1] + DC[a]))
i1, i3 = self.ind[(u, a)], self.outd[(v, OPP[a])]
h = m.NewBoolVar(f"hp{u}{b}{a}")
m.AddImplication(h, i1)
m.AddImplication(h, lit)
m.AddImplication(h, i3)
m.AddBoolOr([i1.Not(), lit.Not(), i3.Not(), h])
self.hp.append(h)
self.hairpins = m.NewIntVar(0, n, "hairpins")
m.Add(self.hairpins == sum(self.hp))
nh = g.nohairpin if nohairpin is None else nohairpin
if nh == 1:
m.Add(self.hairpins == 0)
elif nh == 2:
for h, braced in zip(self.hp, self.hp_braced):
if not braced:
m.Add(h == 0)
for c in g.noturn_cells():
m.Add(sum(self.straight[(c, d)] for d in DIRS) + (self.start[c] if c in self.start else 0) == 1)
# ripple rings: exactly one entry into the ring set (from outside, or by starting there)
for ring in g.ring_sets():
S = set(ring)
entries = []
for c in S:
for d in DIRS:
prev = (c[0] - DR[d], c[1] - DC[d])
if prev in idx and prev not in S:
entries.append(self.x[(prev, d)])
if c in self.start:
entries.append(self.start[c])
m.Add(sum(entries) == 1)
# wave bands / ichimatsu / given grooves: required & forbidden edges
def edge_used(c, d):
nb = (c[0] + DR[d], c[1] + DC[d])
if c == gate and d == g.gate[2]:
return None # the exit itself: always used
if nb not in idx:
return false
return self.x[(c, d)] + self.x[(nb, OPP[d])]
for c, need in g.zone_requirements().items():
for d in need:
e = edge_used(c, d)
if e is not None:
m.Add(e == 1)
for c, want in g.clues.items():
for d in DIRS:
e = edge_used(c, d)
if e is None:
if d not in want:
m.Add(false == 1) # clue forbids the exit direction at the gate: infeasible
continue
m.Add(e == (1 if d in want else 0))
# shape marks: 'S' = straight through (never the rake start); 'B' = a turn (never the rake start)
for c, t in g.marks.items():
m.Add(sum(self.straight[(c, d)] for d in DIRS) == (1 if t == "S" else 0))
if c in self.start:
m.Add(self.start[c] == 0)
def path_from(self, solver):
"""Recover the rake path (P ... gate) from a solved model."""
g = self.g
nxt = {}
for (c, d), lit in self.x.items():
if solver.BooleanValue(lit):
nxt[c] = (c[0] + DR[d], c[1] + DC[d])
start = [c for c, lit in self.start.items() if solver.BooleanValue(lit)][0]
p = [start]
while p[-1] in nxt:
p.append(nxt[p[-1]])
return p
class _Counter(cp_model.CpSolverSolutionCallback):
def __init__(self, model: Model, limit):
super().__init__()
self.model, self.limit, self.n, self.hist = model, limit, 0, {}
def on_solution_callback(self):
self.n += 1
t = self.Value(self.model.turns)
self.hist[t] = self.hist.get(t, 0) + 1
if self.n >= self.limit:
self.StopSearch()
def count_solutions(g: Garden, limit=10**7, workers=1, time_limit=600):
mdl = Model(g)
solver = cp_model.CpSolver()
solver.parameters.enumerate_all_solutions = True
solver.parameters.num_workers = workers
solver.parameters.max_time_in_seconds = time_limit
cb = _Counter(mdl, limit)
st = solver.Solve(mdl.m, cb)
return cb.n, cb.hist, solver.StatusName(st)
def optimize(g: Garden, objective="turns", nohairpin=None, time_limit=120, workers=2, fix=None):
"""Minimise turns (default) or hairpins; returns (value, status, rake path, turns, hairpins).
`fix`: dict like {'hairpins': 0} or {'turns_le': 12} adds constraints first."""
mdl = Model(g, nohairpin=nohairpin)
if fix:
if "hairpins" in fix:
mdl.m.Add(mdl.hairpins == fix["hairpins"])
if "hairpins_le" in fix:
mdl.m.Add(mdl.hairpins <= fix["hairpins_le"])
if "turns_le" in fix:
mdl.m.Add(mdl.turns <= fix["turns_le"])
if "turns_eq" in fix:
mdl.m.Add(mdl.turns == fix["turns_eq"])
if objective is not None:
obj = {"turns": mdl.turns, "hairpins": mdl.hairpins}[objective]
mdl.m.Minimize(obj)
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = time_limit
solver.parameters.num_workers = workers
st = solver.Solve(mdl.m)
name = solver.StatusName(st)
if st in (cp_model.OPTIMAL, cp_model.FEASIBLE):
val = int(solver.ObjectiveValue()) if objective is not None else solver.Value(mdl.turns)
return (val, name, mdl.path_from(solver),
solver.Value(mdl.turns), solver.Value(mdl.hairpins))
return (None, name, None, None, None)
src/zg.py — Garden model, solver driver, and independent brute-force checker.
"""zg.py: garden model, solver driver, and independent brute-force checker.
Garden text symbols:
. plain sand # stone O ripple stone (its 8 neighbours must be raked as one arc)
h sand in a horizontal wave band (lines run the band's full width, unbroken)
v sand in a vertical wave band
= sand in an ichimatsu block raked horizontally; | ... raked vertically
(blocks are maximal same-symbol rectangles; within one block runs are atomic like wave bands)
Given grooves ("the master's raking") are a dict {(r, c): 'EW'|'NS'|'NE'|...}.
Gate = (r, c, side): the rake stroke ends at sand cell (r, c) and leaves through the wall on `side`.
"""
from __future__ import annotations
import subprocess
import os
from dataclasses import dataclass, field
HERE = os.path.dirname(os.path.abspath(__file__))
SOLVER = os.path.join(HERE, "zgsolve")
DIRS = "NESW"
DR = {"N": -1, "E": 0, "S": 1, "W": 0}
DC = {"N": 0, "E": 1, "S": 0, "W": -1}
OPP = {"N": "S", "S": "N", "E": "W", "W": "E"}
SAND = set(".hv=|")
@dataclass
class Garden:
rows: list[str]
gate: tuple[int, int, str]
clues: dict = field(default_factory=dict) # (r,c) -> 'EW' etc
end: tuple[int, int] | None = None
nohairpin: int = 0 # 0: hairpins allowed; 1: forbidden; 2: allowed only when braced against wall/stone (pivot)
braced_turns: bool = False # turns allowed only in cells touching a wall or stone
braced_start: bool = False # the rake must be set down in a cell touching a wall or stone
end_side: str | None = None # two-gate gardens: wall side of the second gate; a bend where the stroke enters there counts as a turn
name: str = ""
marks: dict = field(default_factory=dict) # (r,c) -> 'S' (stroke runs straight through) or 'B' (stroke turns here)
@property
def H(self):
return len(self.rows)
@property
def W(self):
return len(self.rows[0])
def sym(self, r, c):
if 0 <= r < self.H and 0 <= c < self.W:
return self.rows[r][c]
return None
def is_sand(self, r, c):
s = self.sym(r, c)
return s is not None and s in SAND
def blocked(self, r, c):
return not self.is_sand(r, c)
def pivot(self, r, c):
"""Something a wide-rake U-turn (or the rake's start) can be braced against: a wall or a stone. Sand the abbot
has already raked ('R', a finished ring) is not walkable, but it is no pivot: turning against it would rake it."""
s = self.sym(r, c)
return s is None or (s not in SAND and s != "R")
def noturn_cells(self):
if not self.braced_turns:
return []
return [(r, c) for (r, c) in self.sand_cells()
if all(self.is_sand(r + DR[d], c + DC[d]) for d in DIRS)]
def sand_cells(self):
return [(r, c) for r in range(self.H) for c in range(self.W) if self.is_sand(r, c)]
def ring_sets(self):
rings = []
for r in range(self.H):
for c in range(self.W):
if self.rows[r][c] == "O":
ring = [(r - 1, c), (r - 1, c + 1), (r, c + 1), (r + 1, c + 1),
(r + 1, c), (r + 1, c - 1), (r, c - 1), (r - 1, c - 1)]
for rr, cc in ring:
if not self.is_sand(rr, cc):
raise ValueError(f"ripple stone at {(r, c)} lacks full sand ring")
rings.append(ring)
return rings
def zone_requirements(self):
"""Required directions implied by wave bands / ichimatsu blocks: adjacent same-band cells
along the band orientation must be joined by the stroke."""
req = {}
for r in range(self.H):
for c in range(self.W):
s = self.rows[r][c]
if s in "h=":
for d in "EW":
if self.sym(r + DR[d], c + DC[d]) == s:
req.setdefault((r, c), set()).add(d)
elif s in "v|":
for d in "NS":
if self.sym(r + DR[d], c + DC[d]) == s:
req.setdefault((r, c), set()).add(d)
return req
def to_input(self, mode=0, cap=2, nodelimit=None, bound=None, stream=False):
lines = [f"{self.H} {self.W}"]
for row in self.rows:
lines.append("".join("." if ch in SAND else "R" if ch == "R" else "#" for ch in row))
gr, gc, gd = self.gate
lines.append(f"gate {gr} {gc} {gd}")
if self.end is not None:
lines.append(f"end {self.end[0]} {self.end[1]}")
if self.end_side:
lines.append(f"endside {self.end_side}")
lines.append(f"nohairpin {int(self.nohairpin)}")
lines.append(f"mode {mode}")
lines.append(f"cap {cap}")
if nodelimit:
lines.append(f"nodelimit {nodelimit}")
if bound is not None:
lines.append(f"bound {bound}")
if stream:
lines.append("stream 1")
if self.marks:
lines.append(f"shape {len(self.marks)} " + " ".join(f"{r} {c} {t}" for (r, c), t in sorted(self.marks.items())))
if self.braced_start:
ec = [(r, c) for (r, c) in self.sand_cells() if any(self.pivot(r + DR[d], c + DC[d]) for d in DIRS)]
lines.append(f"endcells {len(ec)} " + " ".join(f"{r} {c}" for r, c in ec))
nt = self.noturn_cells()
if nt:
lines.append(f"noturn {len(nt)} " + " ".join(f"{r} {c}" for r, c in nt))
req = self.zone_requirements()
reqlines = []
cells = set(req) | set(self.clues)
for (r, c) in sorted(cells):
need = set(req.get((r, c), set()))
forb = set()
if (r, c) in self.clues:
g = self.clues[(r, c)]
need |= set(g)
forb |= set(DIRS) - set(g)
reqlines.append(f"{r} {c} {''.join(sorted(need)) or '-'} {''.join(sorted(forb)) or '-'}")
lines.append(f"req {len(reqlines)}")
lines.extend(reqlines)
rings = self.ring_sets()
lines.append(f"rings {len(rings)}")
for ring in rings:
lines.append(f"{len(ring)} " + " ".join(f"{r} {c}" for r, c in ring))
return "\n".join(lines) + "\n"
def solve(self, mode=0, cap=2, nodelimit=None, bound=None, timeout=600, stream=False):
"""stream=True returns every solution found (in 'sols'), not just the first 16."""
inp = self.to_input(mode=mode, cap=cap, nodelimit=nodelimit, bound=bound, stream=stream)
p = subprocess.run([SOLVER], input=inp, capture_output=True, text=True, timeout=timeout)
if p.returncode != 0:
raise RuntimeError(p.stderr + "\n" + inp)
return parse_output(p.stdout, self.W)
def parse_output(out, W):
res = {"sols": [], "hist": {}, "hphist": {}}
for line in out.strip().split("\n"):
parts = line.split()
k = parts[0]
if k in ("nsol", "complete", "capped", "nodes"):
res[k] = int(parts[1])
elif k in ("hist", "hphist"):
res[k] = {int(a): int(b) for a, b in (x.split(":") for x in parts[1:])}
elif k in ("best", "worst", "sol"):
turns, n = int(parts[1]), int(parts[2])
cells = [divmod(int(x), W) for x in parts[3:3 + n]]
entry = {"turns": turns, "draw": cells, "rake": list(reversed(cells))}
if k == "sol":
res["sols"].append(entry)
else:
res[k] = entry
return res
def move_dir(a, b):
dr, dc = b[0] - a[0], b[1] - a[1]
for d in DIRS:
if DR[d] == dr and DC[d] == dc:
return d
raise ValueError((a, b))
def path_moves(g: Garden, draw):
"""Moves in drawing order, starting with the entry move through the gate."""
moves = [OPP[g.gate[2]]]
for a, b in zip(draw, draw[1:]):
moves.append(move_dir(a, b))
return moves
def turns_of(g: Garden, draw):
"""Turns along a raking, counting the turn out through the veranda gate and, with a side gate, the bend where the
stroke enters through it (in drawing order: the walk's last move against the way out through the side gate)."""
m = path_moves(g, draw)
t = sum(1 for a, b in zip(m, m[1:]) if a != b)
if g.end is not None and g.end_side and len(draw) > 1 and tuple(draw[-1]) == tuple(g.end):
t += m[-1] != g.end_side
return t
def hairpin_list(g: Garden, draw):
"""Hairpins as (turn cell 1, turn cell 2, bottom direction, braced?) in drawing order."""
m = path_moves(g, draw)
out = []
for k in range(2, len(draw) + 1):
a, b = m[k - 2], m[k - 1]
c = m[k] if k < len(m) else None
if c is not None and c == OPP[a] and b != a:
u, v = draw[k - 2], draw[k - 1]
braced = g.pivot(u[0] + DR[a], u[1] + DC[a]) and g.pivot(v[0] + DR[a], v[1] + DC[a])
out.append((u, v, a, braced))
return out
def hairpins_of(g: Garden, draw):
return len(hairpin_list(g, draw))
def glyphs(g: Garden, draw):
"""Groove glyph per cell as a pair of directions (sorted string); rake-start cell gets 1 direction."""
out = {}
n = len(draw)
for i, cell in enumerate(draw):
ds = set()
if i == 0:
ds.add(g.gate[2])
else:
ds.add(move_dir(cell, draw[i - 1]))
if i + 1 < n:
ds.add(move_dir(cell, draw[i + 1]))
out[cell] = "".join(d for d in DIRS if d in ds)
return out
def check_solution(g: Garden, draw) -> bool:
"""Independent verification of a solution against every rule (used to cross-check the C solver)."""
sand = set(g.sand_cells())
if len(draw) != len(sand) or set(draw) != sand:
return False
if draw[0] != (g.gate[0], g.gate[1]):
return False
for a, b in zip(draw, draw[1:]):
if abs(a[0] - b[0]) + abs(a[1] - b[1]) != 1:
return False
if g.end is not None and draw[-1] != tuple(g.end):
return False
if g.braced_start:
r, c = draw[-1]
if not any(g.pivot(r + DR[d], c + DC[d]) for d in DIRS):
return False
gl = glyphs(g, draw)
for cell, need in g.zone_requirements().items():
if not need <= set(gl[cell]):
return False
for cell, want in g.clues.items():
if set(gl[cell]) != set(want):
return False
for cell, t in g.marks.items():
ds = set(gl[cell])
straight = ds in ({"E", "W"}, {"N", "S"})
if len(ds) != 2 or straight != (t == "S"):
return False
pos = {c: i for i, c in enumerate(draw)}
for ring in g.ring_sets():
ps = sorted(pos[c] for c in ring)
if ps[-1] - ps[0] != len(ring) - 1:
return False
hp = hairpin_list(g, draw)
if g.nohairpin == 1 and hp:
return False
if g.nohairpin == 2 and any(not br for *_, br in hp):
return False
m = path_moves(g, draw)
nt = set(g.noturn_cells())
for k, cell in enumerate(draw[:-1]):
if cell in nt and m[k] != m[k + 1]:
return False
return True
def brute_force(g: Garden, limit=None):
"""Plain exhaustive DFS (no pruning beyond self-avoidance); returns (count, turn histogram)."""
sand = set(g.sand_cells())
start = (g.gate[0], g.gate[1])
n = len(sand)
count = 0
hist = {}
path = [start]
seen = {start}
def rec():
nonlocal count
if len(path) == n:
if check_solution(g, path):
count += 1
t = turns_of(g, path)
hist[t] = hist.get(t, 0) + 1
return
r, c = path[-1]
for d in DIRS:
nxt = (r + DR[d], c + DC[d])
if nxt in sand and nxt not in seen:
seen.add(nxt)
path.append(nxt)
rec()
path.pop()
seen.discard(nxt)
rec()
return count, hist
def ascii_solution(g: Garden, draw):
"""Render the raked pattern with box-drawing glyphs."""
box = {"EW": "─", "NS": "│", "ES": "┌", "SW": "┐", "NE": "└", "NW": "┘",
"N": "╵", "E": "╶", "S": "╷", "W": "╴"}
gl = glyphs(g, draw)
out = []
for r in range(g.H):
line = []
for c in range(g.W):
if (r, c) in gl:
key = "".join(sorted(gl[(r, c)], key=DIRS.index))
# normalise key ordering for lookup
k2 = {"NE": "NE", "ES": "ES", "SW": "SW", "NW": "NW", "EW": "EW", "NS": "NS"}
kk = key
if len(kk) == 2:
for cand in box:
if len(cand) == 2 and set(cand) == set(kk):
kk = cand
line.append(box.get(kk, "?"))
else:
line.append("●" if g.rows[r][c] == "O" else "■")
out.append("".join(line))
return "\n".join(out)
src/zgsolve.c — Exact solver for one-stroke Zen-garden raking puzzles.
/* zgsolve.c: exact solver for one-stroke Zen-garden raking puzzles.
*
* A garden is an H x W grid of sand cells and blocked cells. A solution is a
* Hamiltonian path over the sand cells which ends at the gate cell (and exits
* through the gate in direction gateDir). We search in *drawing order*: from
* the gate inward, which is the reverse of the raking order; turn and hairpin
* counts are invariant under reversal.
*
* Optional constraints:
* - required/forbidden directions per cell (encodes wave bands & given grooves),
* - ring sets (cells that must be raked as one unbroken arc),
* - no-hairpin ("wide rake": no move triple a,b,-a with b perpendicular to a), braced U-turns allowed in mode 2:
* braced = both cells beyond are wall or stone ('#'); finished sand ('R': not walkable) is no pivot,
* - fixed rake-start cell (two-gate gardens); with `endside d`, the stroke enters that cell through a side gate
* in wall side d, and a bend there counts as a turn (as leaving through the veranda gate does),
* - shape marks per cell: 'S' = the stroke runs straight through, 'B' = it turns (either way);
* a marked cell can never be the rake's start.
*
* Modes: 0 = count solutions (up to cap), 1 = branch-and-bound min turns,
* 2 = count solutions with turns <= bound.
*
* Input (stdin): see parse() below. Output: key/value lines for the Python driver.
* Build: gcc -O3 -march=native -Wall -Wextra -o zgsolve zgsolve.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
typedef unsigned __int128 u128;
#define MAXN 128
#define MAXR 64
#define MAXSOL 16
static const int DR[4] = {-1, 0, 1, 0}; /* N E S W */
static const int DC[4] = {0, 1, 0, -1};
static inline int opp(int d) { return (d + 2) & 3; }
static int H, W, N;
static int isSoft[MAXN]; /* 'R': sand the abbot has finished; not walkable, and not a pivot */
static int isSand[MAXN];
static int nb[MAXN][4];
static int piv[MAXN][4]; /* 1 if the cell beyond in direction d is a wall or stone: something a U-turn can pivot on */
static u128 sandMask, blackMask, notCol0, notColLast;
static int gateCell, gateDir, endCell = -1, endSideDir = -1, noHairpin = 0;
static u128 endMask; static int endMaskSet = 0;
static int reqm[MAXN], forbm[MAXN], noTurn[MAXN];
static int shapeReq[MAXN], nShape = 0, shapeCells[MAXN]; /* 1 = straight, 2 = turn */
static int streamAll = 0; /* print every solution as it is found (instead of storing the first MAXSOL) */
static int nRings, ringSize[MAXR], ringCount[MAXR];
static uint64_t ringOf[MAXN];
static long long cap = 2, nsol = 0, nodes = 0, nodeLimit = 4000000000LL;
static int aborted = 0, hitCap = 0;
static int mode = 0, turnBound = 1 << 20;
static long long hist[2 * MAXN + 8], hpHist[MAXN + 8], ubHist[MAXN + 8];
static int path[MAXN], plen = 0;
static int bestTurns = 1 << 20, bestPath[MAXN], bestLen = 0;
static int worstTurns = -1, worstPath[MAXN], worstLen = 0;
static int nStored = 0, stored[MAXSOL][MAXN], storedLen[MAXSOL], storedTurns[MAXSOL];
static int moveSeq[MAXN + 2]; /* moves in drawing order; moveSeq[0] = entry move into gate cell */
static inline int pc(u128 x) { return __builtin_popcountll((uint64_t)x) + __builtin_popcountll((uint64_t)(x >> 64)); }
static inline u128 bit(int i) { return ((u128)1) << i; }
static inline u128 spread(u128 m) {
return m | ((m << 1) & notCol0) | ((m >> 1) & notColLast) | (m << W) | (m >> W);
}
static int countHairpins(int *unbraced) {
/* moveSeq[0..plen-1]: moveSeq[k] is the move INTO path[k] (k=0: entry into gate cell).
Triple (a,b,c) entering path[k-2], path[k-1], path[k]: turn cells are path[k-2] and path[k-1]. */
int h = 0, ub = 0;
for (int k = 2; k < plen; k++) {
int a = moveSeq[k - 2], b = moveSeq[k - 1], c = moveSeq[k];
if (c == opp(a) && b != a) {
h++;
if (!(piv[path[k - 2]][a] && piv[path[k - 1]][a])) ub++;
}
}
*unbraced = ub;
return h;
}
static void record(int turns) {
nsol++;
if (turns < 2 * MAXN + 8) hist[turns]++;
int ub;
int hp = countHairpins(&ub);
if (hp < MAXN + 8) hpHist[hp]++;
if (ub < MAXN + 8) ubHist[ub]++;
if (streamAll) {
printf("sol %d %d", turns, plen);
for (int i = 0; i < plen; i++) printf(" %d", path[i]);
printf("\n");
} else if (nStored < MAXSOL) {
memcpy(stored[nStored], path, plen * sizeof(int));
storedLen[nStored] = plen; storedTurns[nStored] = turns; nStored++;
}
if (turns < bestTurns) { bestTurns = turns; memcpy(bestPath, path, plen * sizeof(int)); bestLen = plen; }
if (turns > worstTurns) { worstTurns = turns; memcpy(worstPath, path, plen * sizeof(int)); worstLen = plen; }
}
/* v: head cell; back: direction from v toward previous path cell (gateDir at the gate);
* m1: move into v; m2: move before that (-1 if none); unv: unvisited sand (excluding v);
* turns: turns at cells before v; active: rings started but not finished. */
static void dfs(int v, int back, int m1, int m2, u128 unv, int turns, uint64_t active) {
if (aborted) return;
if (++nodes > nodeLimit) { aborted = 1; return; }
if (mode == 1 && turns >= bestTurns) return;
if (mode == 2 && turns > turnBound) return;
if (forbm[v] & (1 << back)) return;
int rem = reqm[v] & ~(1 << back);
int remc = __builtin_popcount(rem);
if (remc > 1) return;
if (unv == 0) {
if (remc != 0) return;
if (endCell >= 0 && v != endCell) return;
if (!((endMask >> v) & 1)) return;
int tf = turns + (endSideDir >= 0 && m1 != endSideDir); /* the bend where the stroke enters through a side gate */
if (mode == 2 && tf > turnBound) return;
record(tf);
if (mode != 1 && nsol >= cap) { aborted = 1; hitCap = 1; }
return;
}
u128 vb = bit(v), allowed = unv | vb;
/* connectivity of remaining sand through the head */
u128 reach = vb, nr;
for (;;) { nr = spread(reach) & allowed; if (nr == reach) break; reach = nr; }
if (reach != allowed) return;
/* degree check: every unvisited cell except the eventual rake-start needs >= 2 usable neighbours */
u128 a = (allowed << 1) & notCol0, b = (allowed >> 1) & notColLast, c = allowed << W, d = allowed >> W;
u128 any = a | b | c | d;
if (unv & ~any) return;
u128 ge2 = (a & b) | (a & c) | (a & d) | (b & c) | (b & d) | (c & d);
u128 le1 = unv & ~ge2;
if (le1 & ~endMask) return;
if (pc(le1) > 1) return;
if (!(unv & endMask)) return;
/* checkerboard parity: remaining cells alternate colours starting with the opposite of the head */
int R = pc(unv), headBlack = (int)((blackMask >> v) & 1), nBlack = pc(unv & blackMask);
int expBlack = headBlack ? R / 2 : (R + 1) / 2;
if (nBlack != expBlack) return;
if (endCell >= 0 && ((unv >> endCell) & 1) == 0) return;
/* shape marks on unvisited cells: a straight mark needs both neighbours on one axis usable, a turn mark one on each axis */
for (int i = 0; i < nShape; i++) {
int s = shapeCells[i];
if (!((unv >> s) & 1)) continue;
int u[4];
for (int d = 0; d < 4; d++) u[d] = nb[s][d] >= 0 && ((allowed >> nb[s][d]) & 1);
if (shapeReq[s] == 1 ? !((u[0] && u[2]) || (u[1] && u[3])) : !((u[0] || u[2]) && (u[1] || u[3]))) return;
}
int forced = remc == 1 ? __builtin_ctz(rem) : -1;
for (int e = 0; e < 4; e++) {
if (forced >= 0 && e != forced) continue;
if (forbm[v] & (1 << e)) continue;
int w = nb[v][e];
if (w < 0 || !((unv >> w) & 1)) continue;
if (noHairpin && m2 >= 0 && e == opp(m2) && m1 != m2) {
/* hairpin with turn cells u = v - m1 and v; braced iff both cells beyond in direction m2 are wall or stone */
if (noHairpin == 1) continue;
int u = nb[v][opp(m1)];
if (!(piv[u][m2] && piv[v][m2])) continue;
}
if (noTurn[v] && e != m1) continue;
if (shapeReq[v] == 1 && e != m1) continue;
if (shapeReq[v] == 2 && e == m1) continue;
uint64_t rw = ringOf[w], newActive = active;
for (uint64_t t = rw; t; t &= t - 1) {
int k = __builtin_ctzll(t);
ringCount[k]++;
if (ringCount[k] == ringSize[k]) newActive &= ~(1ULL << k); else newActive |= (1ULL << k);
}
if (!(newActive & ~ringOf[w])) {
path[plen] = w; moveSeq[plen] = e; plen++;
dfs(w, opp(e), e, m1, unv & ~bit(w), turns + (e != m1), newActive);
plen--;
}
for (uint64_t t = rw; t; t &= t - 1) ringCount[__builtin_ctzll(t)]--;
if (aborted) return;
}
}
static int dirOf(char ch) {
switch (ch) { case 'N': return 0; case 'E': return 1; case 'S': return 2; case 'W': return 3; }
return -1;
}
static int maskOf(const char *s) {
int m = 0;
if (strcmp(s, "-") == 0) return 0;
for (; *s; s++) { int d = dirOf(*s); if (d < 0) { fprintf(stderr, "bad dir %c\n", *s); exit(2); } m |= 1 << d; }
return m;
}
static void printPath(const char *tag, int *p, int len, int turns) {
printf("%s %d %d", tag, turns, len);
for (int i = 0; i < len; i++) printf(" %d", p[i]);
printf("\n");
}
/* Read exactly n fields or fail loudly, naming the key being parsed. */
#define READ(n, ...) do { if (scanf(__VA_ARGS__) != (n)) { fprintf(stderr, "malformed input for key '%s'\n", key); exit(2); } } while (0)
int main(void) {
char key[64], buf[256];
if (scanf("%d %d", &H, &W) != 2) return 2;
N = H * W;
if (N > MAXN) { fprintf(stderr, "garden too large\n"); return 2; }
for (int r = 0; r < H; r++) {
if (scanf("%255s", buf) != 1 || (int)strlen(buf) != W) { fprintf(stderr, "bad row %d\n", r); return 2; }
for (int c = 0; c < W; c++) { isSand[r * W + c] = buf[c] == '.'; isSoft[r * W + c] = buf[c] == 'R'; }
}
gateCell = -1;
while (scanf("%63s", key) == 1) {
if (!strcmp(key, "gate")) { int r, c; char ds[8]; READ(3, "%d %d %7s", &r, &c, ds); gateCell = r * W + c; gateDir = dirOf(ds[0]); }
else if (!strcmp(key, "end")) { int r, c; READ(2, "%d %d", &r, &c); endCell = r < 0 ? -1 : r * W + c; }
else if (!strcmp(key, "endside")) { char ds[8]; READ(1, "%7s", ds); endSideDir = dirOf(ds[0]); if (endSideDir < 0) { fprintf(stderr, "bad endside\n"); return 2; } }
else if (!strcmp(key, "nohairpin")) READ(1, "%d", &noHairpin);
else if (!strcmp(key, "mode")) READ(1, "%d", &mode);
else if (!strcmp(key, "bound")) READ(1, "%d", &turnBound);
else if (!strcmp(key, "cap")) READ(1, "%lld", &cap);
else if (!strcmp(key, "nodelimit")) READ(1, "%lld", &nodeLimit);
else if (!strcmp(key, "req")) {
int k; READ(1, "%d", &k);
for (int i = 0; i < k; i++) {
int r, c; char rs[8], fs[8]; READ(4, "%d %d %7s %7s", &r, &c, rs, fs);
reqm[r * W + c] |= maskOf(rs); forbm[r * W + c] |= maskOf(fs);
}
} else if (!strcmp(key, "endcells")) {
int k; READ(1, "%d", &k); endMaskSet = 1;
for (int i = 0; i < k; i++) { int r, c; READ(2, "%d %d", &r, &c); endMask |= bit(r * W + c); }
} else if (!strcmp(key, "shape")) {
int k; READ(1, "%d", &k);
for (int i = 0; i < k; i++) {
int r, c; char ts[8]; READ(3, "%d %d %7s", &r, &c, ts);
if (r < 0 || r >= H || c < 0 || c >= W || (ts[0] != 'S' && ts[0] != 'B')) { fprintf(stderr, "bad shape mark %d %d %s\n", r, c, ts); exit(2); }
shapeReq[r * W + c] = ts[0] == 'S' ? 1 : 2;
}
} else if (!strcmp(key, "stream")) READ(1, "%d", &streamAll);
else if (!strcmp(key, "noturn")) {
int k; READ(1, "%d", &k);
for (int i = 0; i < k; i++) { int r, c; READ(2, "%d %d", &r, &c); noTurn[r * W + c] = 1; }
} else if (!strcmp(key, "rings")) {
READ(1, "%d", &nRings);
if (nRings > MAXR) { fprintf(stderr, "too many rings\n"); return 2; }
for (int k = 0; k < nRings; k++) {
READ(1, "%d", &ringSize[k]);
for (int i = 0; i < ringSize[k]; i++) { int r, c; READ(2, "%d %d", &r, &c); ringOf[r * W + c] |= 1ULL << k; }
}
} else { fprintf(stderr, "unknown key %s\n", key); return 2; }
}
if (gateCell < 0 || !isSand[gateCell]) { fprintf(stderr, "gate must be a sand cell\n"); return 2; }
sandMask = blackMask = notCol0 = notColLast = 0;
for (int r = 0; r < H; r++) for (int c = 0; c < W; c++) {
int v = r * W + c;
if (isSand[v]) sandMask |= bit(v);
if (((r + c) & 1) == 0) blackMask |= bit(v);
if (c != 0) notCol0 |= bit(v);
if (c != W - 1) notColLast |= bit(v);
for (int d = 0; d < 4; d++) {
int rr = r + DR[d], cc = c + DC[d];
int inside = rr >= 0 && rr < H && cc >= 0 && cc < W;
nb[v][d] = (inside && isSand[rr * W + cc]) ? rr * W + cc : -1;
piv[v][d] = !inside || (!isSand[rr * W + cc] && !isSoft[rr * W + cc]);
}
}
if (!endMaskSet) endMask = sandMask;
if (endCell >= 0) endMask &= bit(endCell);
for (int v = 0; v < N; v++) if (shapeReq[v]) {
if (!isSand[v]) { fprintf(stderr, "shape mark on a non-sand cell\n"); return 2; }
endMask &= ~bit(v); /* the rake's start has one connection: never a marked cell */
shapeCells[nShape++] = v;
}
path[0] = gateCell; moveSeq[0] = opp(gateDir); plen = 1;
uint64_t active = 0;
for (uint64_t t = ringOf[gateCell]; t; t &= t - 1) {
int k = __builtin_ctzll(t); ringCount[k]++;
if (ringCount[k] < ringSize[k]) active |= 1ULL << k;
}
dfs(gateCell, gateDir, opp(gateDir), -1, sandMask & ~bit(gateCell), 0, active);
printf("nsol %lld\n", nsol);
printf("complete %d\n", (aborted && !hitCap) ? 0 : 1);
printf("capped %d\n", hitCap);
printf("nodes %lld\n", nodes);
printf("hist");
for (int t = 0; t < 2 * MAXN + 8; t++) if (hist[t]) printf(" %d:%lld", t, hist[t]);
printf("\nhphist");
for (int t = 0; t < MAXN + 8; t++) if (hpHist[t]) printf(" %d:%lld", t, hpHist[t]);
printf("\nubhist");
for (int t = 0; t < MAXN + 8; t++) if (ubHist[t]) printf(" %d:%lld", t, ubHist[t]);
printf("\n");
if (bestLen) printPath("best", bestPath, bestLen, bestTurns);
if (worstLen) printPath("worst", worstPath, worstLen, worstTurns);
for (int i = 0; i < nStored; i++) printPath("sol", stored[i], storedLen[i], storedTurns[i]);
return 0;
}
The prototype and its tests (proto/)
proto/build.py — Build the prototype: samon.html (artifact page body: no doctype/head, per the artifact page contract)
"""Build the prototype: samon.html (artifact page body: no doctype/head, per the artifact page contract)
and samon.standalone.html (a complete document, for self-hosting)."""
import json
import sys
sys.path.insert(0, '/home/claude/zen/src')
from poems import attach
from sound_pieces import pieces_html
import os
G = json.load(open('/home/claude/zen/out/proto_gardens.json'))
practice = attach(G) # each garden's poem (poems/samon-poems.json), and the practice garden's
DAILY_PATH = '/home/claude/zen/out/daily_gardens.json' # a year of daily gardens (src/daily.py); the page does without if absent
DAILY = json.load(open(DAILY_PATH)) if os.path.exists(DAILY_PATH) else []
if not DAILY: print('warning: no daily gardens (out/daily_gardens.json missing)')
s = open('/home/claude/zen/proto/samon.template.html').read()
assert s.count('__GARDENS__') == 1 and s.count('__PRACTICE_POEM__') == 1 and s.count('__DAILY__') == 1 and s.count('__PIECES__') == 1
s = s.replace('__GARDENS__', json.dumps(G, separators=(',', ':'), ensure_ascii=False)).replace(
'__PRACTICE_POEM__', json.dumps(practice, separators=(',', ':'), ensure_ascii=False)).replace(
'__DAILY__', json.dumps(DAILY, separators=(',', ':'), ensure_ascii=False)).replace(
'__PIECES__', pieces_html()) # the recordings (out/sound_pieces.json; sound/make_sound_bundle.py)
open('/home/claude/zen/proto/samon.html', 'w').write(s)
print(len(s), 'bytes: samon.html')
split = s.index('<div class="wrap">')
head, body = s[:split], s[split:]
SOCIAL = ('<meta name="description" content="Samon (砂紋): a Zen-garden raking puzzle. Walk the monk in from the gate, cover every cell of sand in one stroke, and he rakes his way back out. Playable prototype.">\n'
'<meta name="author" content="Gwern Branwen">\n'
'<meta property="og:type" content="website">\n<meta property="og:site_name" content="Gwern.net">\n<meta property="og:title" content="Samon 砂紋">\n'
'<meta property="og:description" content="A Zen-garden raking puzzle: walk the monk in from the gate, cover every cell of sand in one stroke, and he rakes his way back out. 148 gardens, a daily garden, and a poem for each.">\n'
'<meta property="og:image" content="https://gwern.net/doc/design/2026-10-05-gwern-samon-thumbnail.png">\n<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="630">\n'
'<meta property="og:image:alt" content="A raked Zen garden with the abbot\'s vermilion seal, beside the title Samon 砂紋.">\n'
'<meta name="twitter:card" content="summary_large_image">\n<meta name="twitter:site" content="@gwern">\n<meta name="twitter:creator" content="@gwern">\n'
'<meta name="twitter:title" content="Samon 砂紋">\n<meta name="twitter:description" content="A Zen-garden raking puzzle: one stroke, every cell once, ending at the gate. 148 gardens, a daily garden, and a poem for each.">\n'
'<meta name="twitter:image" content="https://gwern.net/doc/design/2026-10-05-gwern-samon-thumbnail.png">\n')
doc = ('<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n' + SOCIAL
+ head + '</head>\n<body>\n' + body + '</body>\n</html>\n')
open('/home/claude/zen/proto/samon.standalone.html', 'w').write(doc)
print(len(doc), 'bytes: samon.standalone.html')
proto/samon.template.html
<title>Samon</title>
<style>
/* Layout: the garden is the page; one quiet row below it carries the count and the controls.
Style: gwern.net's (gwern.net/design, /style-guide): black on white, greys for everything secondary, no colour but the
vermilion (the novice's ensō and the abbot's seal, the one accent the design keeps), serif text with sans controls,
square corners, hairline rules, no shadows. Dark mode is gwern.net's #161616 / #f1f1f1. No web fonts: system stacks,
with the system's CJK fonts for 砂紋, the poems' Japanese and the seal's 静. */
:root {
--bg: #ffffff; /* paper */
--fg: #000000; /* ink */
--muted: #767676; /* secondary text (AA on white) */
--line: #cccccc; /* hairlines */
--sand: #f3f3f1; /* the raked gravel; the grain and grooves are drawn over it */
--sand-dot: #d9d9d6;
--groove: #8a8a8a;
--groove-hi: #ffffff;
--ghost: #bdbdbd; /* the abbot's sketched groove */
--stone: #262626;
--stone-hi: #5a5a5a;
--wall: #000000;
--mark: #000000; /* the abbot's marks and hint highlights (drawn thin, dashed, or faint) */
--foot: #9a9a9a;
--seal: #c23b22; /* vermilion: the novice's ensō, the abbot's seal, a side gate's exit mark, and nothing else */
--font-display: "Source Serif 4", "Source Serif Pro", "Apple Garamond", Baskerville, "Libre Baskerville", "Hiragino Mincho ProN", "Yu Mincho", "Noto Serif CJK JP", "Times New Roman", Times, serif;
--font-ui: "Source Sans 3", "Source Sans Pro", "Lucida Sans Unicode", Helvetica, "Trebuchet MS", "Hiragino Sans", "Noto Sans CJK JP", sans-serif;
--step--1: 0.8125rem;
--step-0: 0.9375rem;
--step-1: 1.125rem;
--step-2: 1.5rem;
--step-3: 2.1rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #161616; --fg: #f1f1f1; --muted: #9a9a9a; --line: #444444;
--sand: #343434; --sand-dot: #3c3c3c; --groove: #0e0e0e; --groove-hi: #8a8a8a; --ghost: #666666;
--stone: #050505; --stone-hi: #3c3c3c; --wall: #000000; --mark: #f1f1f1; --foot: #5e5e5e; --seal: #d9452c;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #161616; --fg: #f1f1f1; --muted: #9a9a9a; --line: #444444;
--sand: #343434; --sand-dot: #3c3c3c; --groove: #0e0e0e; --groove-hi: #8a8a8a; --ghost: #666666;
--stone: #050505; --stone-hi: #3c3c3c; --wall: #000000; --mark: #f1f1f1; --foot: #5e5e5e; --seal: #d9452c;
color-scheme: dark;
}
[hidden] { display: none !important; } /* .seal sets display:grid, which would otherwise beat the UA's [hidden] rule */
body:not(.practice) .practice-only, body.practice .puzzle-only { display: none !important; }
body:not(.poems) .poems-only, body.poems .puzzle-only, body.poems .practice-only, body.poems .stage, body.poems .veranda { display: none !important; }
/* the scroll of poems earned */
.scroll { display: grid; gap: 12px; padding: 2px 0 6px; }
.scroll h3 { font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin: 12px 0 0; font-weight: 600; }
.entry { border-top: 1px solid var(--line); padding-top: 10px; }
.entry .verse { font-family: var(--font-display); font-size: var(--step-1); line-height: 1.5; white-space: pre-line; }
.entry .by { font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.06em; color: var(--muted); margin-top: 4px; padding-left: 3.5em; }
.entry .by button { font: inherit; color: inherit; background: none; border: 0; padding: 0; text-decoration: underline dotted; text-underline-offset: 3px; cursor: pointer; }
.entry .ja { font-family: var(--font-display); font-size: var(--step-0); letter-spacing: 0.06em; line-height: 1.6; margin-top: 6px; }
.entry .src { font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); margin-top: 2px; }
.entry .src a { color: inherit; }
.scroll .left { color: var(--muted); font-size: var(--step--1); margin: 0; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font-family: var(--font-display); font-size: var(--step-0); line-height: 1.55;
padding-inline: 16px; padding-block: 0;
}
.wrap { max-width: 600px; margin: 0 auto; padding-block: 18px 40px; display: grid; gap: 14px; }
header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.brand { font-family: var(--font-display); font-weight: 700; font-size: var(--step-3); letter-spacing: 0.01em; line-height: 1; }
.brand span { font-weight: 400; font-size: var(--step-1); color: var(--muted); margin-left: 0.4em; letter-spacing: 0.15em; }
header .right { margin-left: auto; display: flex; align-items: center; gap: 12px; min-width: 0; position: relative; }
.where { text-align: right; min-width: 0; }
.sound { display: inline-flex; flex: none; align-items: stretch; }
.mute { flex: none; width: 34px; height: 34px; padding: 0; display: inline-grid; place-items: center; color: var(--fg); border: 1px solid var(--line); background: transparent; }
.mute:hover { border-color: var(--fg); }
.more-sound { flex: none; width: 20px; height: 34px; padding: 0 0 2px; border: 1px solid var(--line); border-left: 0; background: transparent; color: var(--muted); font-size: 11px; line-height: 1; }
.more-sound:hover, .more-sound[aria-expanded="true"] { color: var(--fg); background: transparent; }
.sound-panel { position: absolute; right: 0; top: calc(100% + 6px); z-index: 5; display: grid; gap: 8px; justify-items: start; min-width: 13em; padding: 10px 12px; background: var(--bg); border: 1px solid var(--line); }
.vol-row { display: flex; align-items: center; gap: 8px; font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.06em; color: var(--muted); }
.mute .slash { display: none; }
.mute.off .slash { display: block; }
.mute.off .wave { opacity: 0.25; }
.mute.blocked { color: var(--seal); border-color: var(--seal); }
.mute.blocked .slash { display: block; }
.mute-hint { position: absolute; right: 0; bottom: calc(100% + 1px); white-space: nowrap; font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); letter-spacing: 0.04em; animation: hintfade 7s ease-in forwards; pointer-events: none; }
@keyframes hintfade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }
.where .time { font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
.where .gname { font-family: var(--font-display); font-size: var(--step-2); line-height: 1.15; text-wrap: balance; }
.stage { position: relative; width: 100%; }
canvas { display: block; width: 100%; height: auto; touch-action: none; user-select: none; -webkit-user-select: none; }
canvas:focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
.seal {
position: absolute; right: 6%; bottom: 7%; width: 74px; height: 74px;
background: var(--seal); color: #fff; display: grid; place-items: center;
font-family: var(--font-display); font-weight: 700; font-size: 44px; line-height: 1;
transform: rotate(-6deg); opacity: 0.94; pointer-events: none; user-select: none; -webkit-user-select: none;
}
.seal.grey { background: #8a8a8a; }
.seal.stamp { animation: stamp 420ms cubic-bezier(.2,.9,.3,1.2) both; }
.seal.under { animation: none; opacity: 0.2; } /* (only when a poem's text cannot be laid clear of it) */
@keyframes stamp { from { transform: scale(1.6) rotate(-14deg); opacity: 0; } to { transform: scale(1) rotate(-6deg); opacity: 0.94; } }
.veranda {
color: var(--fg); border-bottom: 1px solid var(--line);
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
padding: 4px 0 12px;
}
.count { display: flex; gap: 18px; align-items: baseline; font-variant-numeric: tabular-nums; }
.count b { font-family: var(--font-display); font-size: var(--step-2); font-weight: 700; }
.count small { font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-right: 6px; }
.buttons { display: flex; gap: 6px; flex-wrap: wrap; }
button {
font: inherit; font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.02em;
background: transparent; color: var(--fg); border: 1px solid var(--fg);
border-radius: 0; padding: 5px 11px; cursor: pointer;
}
button:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); }
button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
button:disabled { opacity: 0.4; cursor: default; }
button:disabled:hover { background: transparent; }
button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); font-weight: 600; }
button.primary:hover { background: color-mix(in srgb, var(--fg) 82%, var(--bg)); }
.veranda button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
.status { min-height: 1.55em; font-size: var(--step-0); color: var(--fg); }
.status.warn { font-style: italic; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: -6px; }
.actions:empty { display: none; }
.act, .toggle, .pill { color: var(--fg); border: 1px solid var(--line); background: transparent; }
.act { border-color: var(--fg); font-weight: 600; }
.actions .share { flex: 1 0 100%; font-family: ui-monospace, Menlo, Consolas, "DejaVu Sans Mono", monospace; font-size: 13px; line-height: 1.25; color: var(--fg); background: transparent; border: 1px solid var(--line); padding: 6px 8px; resize: vertical; white-space: pre-wrap; overflow-wrap: anywhere; }
.actions .share:focus { outline: 1px solid var(--fg); outline-offset: 0; }
.act:hover, .toggle:hover, .pill:hover { border-color: var(--fg); }
.assists { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.assists .t, nav .world .t { font-family: var(--font-ui); font-size: var(--step--1); letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); }
.assists .t { margin-right: 4px; }
.toggle[aria-pressed="true"] { background: var(--fg); color: var(--bg); border-color: var(--fg); }
.toggle[aria-pressed="true"]::before { content: "✓ "; }
.rule { border-left: 1px solid var(--line); background: color-mix(in srgb, var(--fg) 3%, transparent); padding: 6px 12px; color: var(--fg); margin: 0; }
.rule .facts { display: block; font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); letter-spacing: 0.01em; margin-top: 2px; }
nav.gardens { display: grid; gap: 8px; }
nav .world { display: grid; grid-template-columns: 7.5em 1fr; gap: 10px; align-items: center; }
nav .world .pills { display: flex; gap: 6px; flex-wrap: wrap; min-width: 0; }
.pill { font-size: var(--step--1); padding: 4px 10px; cursor: pointer; display: inline-flex; gap: 6px; align-items: center; }
.pill[aria-current="true"] { border-color: var(--fg); font-weight: 600; }
.pill .dot { width: 8px; height: 8px; border-radius: 50%; border: 1px solid var(--muted); }
input.vol { width: 88px; margin: 0 2px; accent-color: var(--fg); align-self: center; cursor: pointer; }
.pill.done .dot { background: var(--muted); }
.pill.sealed .dot, .pill.grey .dot { border-radius: 0; } /* a seal is square; a garden raked off par is a round dot */
.pill.sealed .dot { background: var(--fg); border-color: var(--fg); }
.pill.grey .dot { background: var(--muted); border-color: var(--muted); }
.pip { font-size: var(--step--1); min-width: 2.1em; padding: 3px 4px; color: var(--fg); border: 1px solid var(--line);
background: transparent; font-variant-numeric: tabular-nums; cursor: pointer; line-height: 1.3; }
.pip:hover { border-color: var(--fg); background: transparent; }
.pip.done { background: color-mix(in srgb, var(--fg) 12%, transparent); }
.pip.sealed { background: var(--fg); border-color: var(--fg); color: var(--bg); } /* the seals collected */
.pip.grey { background: color-mix(in srgb, var(--fg) 45%, var(--bg)); border-color: transparent; color: var(--bg); }
.pip[aria-current="true"] { outline: 2px solid var(--fg); outline-offset: 1px; font-weight: 700; }
.pip:disabled { opacity: 0.32; cursor: default; }
.pip.extra { border-style: dashed; }
nav .world .more { font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); align-self: center; margin-left: 4px; }
.pip:disabled:hover { border-color: var(--line); }
nav .world .lock { font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); align-self: center; }
nav .world.locked { opacity: 0.75; }
nav .world .fold { font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); background: none; border: 0; padding: 3px 4px; text-decoration: underline dotted; text-underline-offset: 3px; cursor: pointer; align-self: center; }
nav .world .fold:hover { color: var(--fg); background: none; }
.openall { background: none; border: 0; padding: 2px 0; color: var(--muted); text-decoration: underline; font: inherit; font-family: var(--font-ui); font-size: var(--step--1); cursor: pointer; }
.openall:hover { background: none; color: var(--fg); }
nav .world .pills { gap: 4px; }
.help { font-size: var(--step--1); color: var(--muted); border-top: 1px solid var(--line); padding-top: 10px; margin: 0; }
details.help summary { cursor: pointer; list-style: none; font-family: var(--font-ui); letter-spacing: 0.04em; }
details.help summary::-webkit-details-marker { display: none; }
details.help summary::before { content: "▸ "; display: inline-block; width: 1em; }
details.help[open] summary::before { content: "▾ "; }
details.help p { margin: 6px 0 0; }
.help kbd { font-family: var(--font-ui); border: 1px solid var(--line); padding: 0 5px; }
@media (max-width: 420px) {
nav .world { grid-template-columns: 1fr; gap: 4px; }
.seal { width: 58px; height: 58px; font-size: 34px; }
}
/* the poem: whatever the raking, a poem is its reward, laid over the raked garden; then the garden gives way to the next */
.poem { position: absolute; inset: 0; display: grid; place-items: center; padding: 4% 6%; cursor: pointer; overflow: auto; scrollbar-width: none;
opacity: 0; transition: opacity 1.6s ease; } /* (inset to the sand by script: the walls stay dark) */
.poem.on { opacity: 1; }
.poem .card { max-width: 30em; font-size: var(--poem-fs, 1.15rem); transform: translateY(6px); transition: transform 1.8s ease;
padding: 0.75em 1em 0.6em; background: var(--bg); border: 1px solid var(--fg); }
.poem.on .card { transform: none; }
.poem .verse { font-family: var(--font-display); font-weight: 400; line-height: 1.55; white-space: pre-line; color: var(--fg); }
.poem .poet { margin-top: 0.6em; padding-left: 3.5em; font-family: var(--font-ui); font-size: 0.72em; letter-spacing: 0.08em; color: var(--muted); } /* (the attribution is set in by an indent, with no dash) */
.poem .orig { margin: 0 0 0 0.6em; padding: 0 2px; border: 0; background: none; color: var(--muted); font: inherit;
letter-spacing: 0.06em; text-decoration: underline dotted; text-underline-offset: 3px; cursor: pointer; }
.poem .orig:hover { background: none; color: var(--fg); }
.poem .ja { margin-top: 0.7em; font-family: var(--font-display); font-size: 0.86em; line-height: 1.6; letter-spacing: 0.06em; color: var(--fg); }
.poem .src { font-family: var(--font-ui); font-size: 0.68em; color: var(--muted); margin-top: 0.2em; }
.poem .src a { color: inherit; }
.poem .tick { height: 1px; width: 0; margin-top: 0.8em; background: var(--fg); opacity: 0.5; }
.poem .tick.run { animation: tick var(--hold, 10s) linear forwards; }
@keyframes tick { to { width: 100%; } }
.stage.leaving, .where.leaving { opacity: 0; transition: opacity var(--fade, 1.2s) ease; }
.stage.arriving, .where.arriving { animation: arrive var(--fade, 1.2s) ease both; }
@keyframes arrive { from { opacity: 0; } to { opacity: 1; } }
.ppoem { margin: 0; font-family: var(--font-display); white-space: pre-line; color: var(--fg); }
.ppoem figcaption { font-family: var(--font-ui); font-size: var(--step--1); color: var(--muted); letter-spacing: 0.1em; margin-top: 0.4em; padding-left: 3.5em; white-space: normal; }
@media (prefers-reduced-motion: reduce) {
.poem, .poem .card { transition: none; } .poem .tick.run { animation: none; }
.stage.leaving, .where.leaving { transition: none; } .stage.arriving, .where.arriving { animation: none; }
}
/* the monk waits in the gateway: a ring beckons three times (then rests: the idea has been had), and (in the first garden, or
until the first walk) a ghostly finger shows the drag, three times too */
.beckon { position: absolute; left: 0; top: 0; border-radius: 50%; border: 1.5px solid var(--fg); pointer-events: none;
transform: translate(-50%, -50%); animation: beckon 1.9s ease-out 3 forwards; }
@keyframes beckon { from { opacity: 0.9; transform: translate(-50%, -50%) scale(0.7); } to { opacity: 0; transform: translate(-50%, -50%) scale(1.7); } }
.finger { position: absolute; left: 0; top: 0; width: 32px; height: 32px; margin: -16px 0 0 -16px; border-radius: 50%; pointer-events: none;
background: color-mix(in srgb, var(--fg) 34%, transparent); outline: 1px solid var(--bg); opacity: 0; }
@media (prefers-reduced-motion: reduce) { .seal.stamp { animation: none; } .beckon { animation: none; opacity: 0.75; } .finger { display: none; } }
</style>
<div class="wrap">
<header>
<div class="brand" title="Samon (砂紋, “sand pattern”): the lines a rake leaves in the sand of a Zen garden">Samon<span>砂紋</span></div>
<div class="right">
<div class="where" id="where" title="The world (a time of day) and this garden’s place in it"><div class="time" id="time">Dawn</div><div class="gname" id="gname">The corridor</div></div>
<span class="sound">
<button class="mute" id="mute" type="button" aria-pressed="false" aria-label="Mute" title="Mute (M)">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
<path class="horn" d="M4 9.5v5h3.2L12 18.5v-13L7.2 9.5H4z" fill="currentColor"/>
<path class="wave w1" d="M15 9.4a3.6 3.6 0 0 1 0 5.2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
<path class="wave w2" d="M17.4 7a7 7 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
<path class="slash" d="M4.5 4.5l15 15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
</svg>
</button>
<button class="more-sound" id="soundMore" type="button" aria-expanded="false" aria-controls="soundPanel" aria-label="Sound settings" title="Sound: the switch, the volume, and haptics">▾</button>
</span>
<div class="sound-panel" id="soundPanel" hidden>
<button class="pill" id="soundToggle" type="button" aria-pressed="true" title="The garden's sounds: a bamboo pipe drips, wind, birds by day and crickets by night, the rake, a bell at the close of each world, and a shakuhachi recording now and then">Sound on</button>
<label class="vol-row" id="volRow"><span>Volume</span><input id="soundVol" class="vol" type="range" min="0" max="100" step="1" title="Volume" aria-label="Volume"></label>
<button class="pill" id="hapticsToggle" type="button" aria-pressed="true" hidden title="A tick in the hand for each cell walked">Haptics on</button>
</div>
</div>
</header>
<div class="stage" id="stage">
<canvas id="garden" tabindex="0" aria-label="Garden. Drag from the gate to walk in."></canvas>
<div class="seal" id="seal" hidden aria-label="The abbot's seal: raked at par" title="The abbot’s seal (静, “stillness”): raked at par">静</div>
<div class="beckon" id="beckon" hidden aria-hidden="true"></div>
<div class="finger" id="finger" hidden aria-hidden="true"></div>
<div class="poem" id="poem" hidden aria-live="polite" title="Tap beside the poem to stay with this garden">
<div class="card" title="Tap the poem to go on to the next garden">
<div class="verse" id="verse" lang="en"></div>
<div class="poet"><span id="poet"></span><button class="orig" id="orig" type="button" aria-label="Show the original Japanese and the source" title="Show the original Japanese and its source">original</button></div>
<div class="ja" id="ja" lang="ja" hidden></div>
<div class="src" id="src" hidden></div>
<div class="tick" id="tick"></div>
</div>
</div>
</div>
<section class="scroll poems-only" id="scroll" aria-label="Poems earned"></section>
<div class="veranda">
<div class="count puzzle-only">
<span title="Changes of direction in your walk so far: the fewer, the calmer the raking"><small>Turns</small><b id="turns">0</b></span>
<span title="The fewest turns any raking of this garden can have (the solver has proved it): rake at par for the abbot’s seal"><small>Par</small><b id="par">0</b></span>
</div>
<div class="buttons puzzle-only">
<button id="undo" type="button" title="Take back the last footprint (Backspace); after raking, walk back one cell">Step back</button>
<button id="restore" type="button" hidden title="Bring back the walk that Start over, or stepping back to a footprint, discarded">Restore</button>
<button id="restart" type="button" title="Smooth the sand and begin again at the gate (R); Restore brings the walk back">Start over</button>
<button id="hint" type="button" title="The abbot judges your own walk: whether it can still reach par, or still be finished at all (H). Where to step back to counts as his help, and his seal is then grey">Ask the abbot</button>
<button id="watch" type="button" hidden title="See the monk rake this garden once more; it counts for nothing">Watch</button>
<button id="rakeDay" type="button" class="primary" hidden title="The daily garden is raked once: this raking is today's record">Rake</button>
<button id="next" type="button" class="primary" hidden title="On to the next garden (Enter)">Next garden</button>
</div>
<div class="count practice-only"><span title="A garden to rake freely: nothing is counted here"><small>Practice</small><b>No goal</b></span></div>
<div class="buttons practice-only">
<button id="toolRake" type="button" aria-pressed="true" title="Drag to rake the sand">Rake</button>
<button id="toolStone" type="button" aria-pressed="false" title="Tap to set a stone down, or to lift one; the sand under it keeps its grooves">Stones</button>
<button id="tines" type="button" title="How many tines the rake has: more tines, more lines to a stroke">3 tines</button>
<button id="smooth" type="button" title="Rake the whole garden flat again">Smooth</button>
</div>
</div>
<div class="status" id="status" role="status" aria-live="polite"></div>
<div class="actions" id="actions"></div>
<div class="assists puzzle-only" role="group" aria-label="Assists">
<span class="t" title="Ways of seeing the garden that make it easier; none of them counts as the abbot’s help">Assists</span>
<button class="toggle" id="aPreview" type="button" aria-pressed="false" title="Show the grooves your walk would rake, as you draw it">Preview grooves</button>
<button class="toggle" id="aChecker" type="button" aria-pressed="false" title="Shade the cells in two colours, like a chessboard: a walk alternates them, so counting what is left tells where it can end">Checkerboard</button>
<button class="toggle" id="aCut" type="button" aria-pressed="false" title="Mark the cells of sand your walk can no longer reach">Cut-off sand</button>
</div>
<p class="rule" id="rule"></p>
<nav class="gardens" id="nav" aria-label="Gardens"></nav>
<details class="help puzzle-only" id="howto">
<summary title="How the game is played, in a few lines; the controls say more when hovered">[How to play]</summary>
<p>[Drag the monk in from the gate and cover every cell of sand without crossing your path; when you let go, he sets down his rake and walks backwards out, raking. Tap a footprint to step back to it, or the gate to start over; after raking, tap any cell to walk back to it. Fewer turns is calmer: rake at par for the abbot's seal. Each raking brings a poem, then the next garden. Keyboard: arrows walk, <kbd>Backspace</kbd> steps back, <kbd>R</kbd> starts over, <kbd>H</kbd> asks the abbot, <kbd>Enter</kbd> goes on.]</p>
</details>
<figure class="ppoem practice-only" id="ppoem"></figure>
<p class="help practice-only">[Drag anywhere to rake. With <b>Stones</b> selected, tap to place a stone or lift one; the sand under a stone keeps whatever was there. <b>Smooth</b> rakes the garden flat again.]</p>
</div>
<!-- the recordings (sound/make_sound_bundle.py → out/sound_pieces.json): each a data: URL in an inert script, which a gwtar build
extracts into its archive and the page then reads back through the gwtar loader (see samonAsset below) -->
__PIECES__
<script>
const GARDENS = __GARDENS__;
const DAILY = __DAILY__; // a year of daily gardens (out/daily_gardens.json); [] if none were built
const NCAMP = GARDENS.length; // the campaign; the daily garden, if any, is appended after it
const PRACTICE_POEM = __PRACTICE_POEM__;
const TIMES = ["", "Dawn", "Morning", "Noon", "Afternoon", "Evening", "Dusk", "Night"];
const DN = "NESW"; // direction indices: 0 N, 1 E, 2 S, 3 W
const DR = [-1, 0, 1, 0], DC = [0, 1, 0, -1];
const DIX = { N: 0, E: 1, S: 2, W: 3 };
const DIRS = { N: [-1, 0], E: [0, 1], S: [1, 0], W: [0, -1] };
const OPP = { N: "S", S: "N", E: "W", W: "E" };
const opp = d => (d + 2) & 3;
const SAND = new Set([".", "h", "v", "=", "|"]);
const TINES = [0.2, 0.5, 0.8];
const RING8 = [[-1, 0], [-1, 1], [0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1]];
const VIEW_EL = document.body; // carries the view classes (practice, poems); the single-file edition puts them on the game's own element
const cv = document.getElementById("garden");
const ctx = cv.getContext("2d");
const el = id => document.getElementById(id);
let view = "puzzle"; // "puzzle" or "practice"
let idx = 0, G = null, path = [], mode = "draw", dragging = false, anim = null;
let cut = null; // the route discarded by the last rewind or restart: one Restore brings it back
let shared = null; // the raking on show came from a #r= link: another player's, shown but not credited ({ turns, par, kind })
let rakeTimer = null, doneAt = 0; // a complete walk is raked after a short beat; when the last raking finished
let started = false, demoOnce = false; // has this player ever started a walk; show the drag once more (after a press elsewhere)
let obs = []; // the cells the abbot pointed at with his observation (until another garden)
let rakedHelped = false, cutHelped = false; // the last raking was made with help; the route Restore would bring back was
let openAll = false; // prototype convenience: every world open
try { openAll = localStorage.getItem("samon-open-all") === "1"; } catch (e) {}
try { started = localStorage.getItem("samon-started") === "1"; } catch (e) {}
if (!started) el("howto").open = true; // the help stands open until the first walk, and is folded away after (it can be unfolded)
let ghosts = new Map(); // cell -> Set of directions: grooves the abbot has sketched for you
let marks = { cells: [], target: -1, checker: false }; // what the last hint pointed at
let hintPresses = 0; // since the route last changed
let helped = false; // the abbot has helped with this garden since it was last raked: its seal can only be grey
let progress = {}, assists = { preview: false, checker: false, cutoff: false };
try { progress = JSON.parse(localStorage.getItem("samon-progress") || "{}") || {}; } catch (e) { progress = {}; }
try { Object.assign(assists, JSON.parse(localStorage.getItem("samon-assists") || "{}") || {}); } catch (e) {}
function saveProgress() { try { localStorage.setItem("samon-progress", JSON.stringify(progress)); } catch (e) {} }
function saveAssists() { try { localStorage.setItem("samon-assists", JSON.stringify(assists)); } catch (e) {} }
/* ---------- garden model ---------- */
function sym(g, r, c) { return (r >= 0 && r < g.rows.length && c >= 0 && c < g.rows[0].length) ? g.rows[r][c] : null; }
function buildGarden(g) {
const H = g.rows.length, W = g.rows[0].length, N = H * W;
const sand = new Uint8Array(N), nbr = new Int16Array(N * 4).fill(-1), braced = new Uint8Array(N), black = new Uint8Array(N);
const piv = new Uint8Array(N * 4); // a wall or a stone beyond the cell: something a wide-rake U-turn can pivot on
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) if (SAND.has(g.rows[r][c])) sand[r * W + c] = 1;
for (let v = 0; v < N; v++) {
const r = Math.floor(v / W), c = v % W;
black[v] = (r + c) % 2 === 0 ? 1 : 0;
for (let d = 0; d < 4; d++) {
const rr = r + DR[d], cc = c + DC[d], inside = rr >= 0 && rr < H && cc >= 0 && cc < W;
if (inside && sand[rr * W + cc]) nbr[v * 4 + d] = rr * W + cc;
// the abbot's finished sand ('R') can't be walked on, but it is no pivot: turning against it would rake it
if (!inside || (!sand[rr * W + cc] && g.rows[rr][cc] !== "R")) { piv[v * 4 + d] = 1; braced[v] = 1; }
}
}
const req = new Uint8Array(N), forb = new Uint8Array(N), mark = new Uint8Array(N);
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
const s = g.rows[r][c], v = r * W + c;
if (s === "h" || s === "=") for (const d of [1, 3]) if (sym(g, r + DR[d], c + DC[d]) === s) req[v] |= 1 << d;
if (s === "v" || s === "|") for (const d of [0, 2]) if (sym(g, r + DR[d], c + DC[d]) === s) req[v] |= 1 << d;
}
for (const [k, ds] of Object.entries(g.clues || {})) {
const [r, c] = k.split(",").map(Number), v = r * W + c;
for (let d = 0; d < 4; d++) { if (ds.includes(DN[d])) req[v] |= 1 << d; else forb[v] |= 1 << d; }
}
const markList = [];
for (const [k, t] of Object.entries(g.marks || {})) { const [r, c] = k.split(",").map(Number); mark[r * W + c] = t === "S" ? 1 : 2; markList.push(r * W + c); }
const rings = [], ringOf = Array.from({ length: N }, () => []);
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) if (g.rows[r][c] === "O") {
const ring = RING8.map(([a, b]) => (r + a) * W + (c + b));
ring.forEach(k => ringOf[k].push(rings.length)); rings.push(ring);
}
const sandList = []; for (let v = 0; v < N; v++) if (sand[v]) sandList.push(v);
const keyOf = ([r, c]) => r * W + c;
return { ...g, H, W, N, sand, sandList, nSand: sandList.length, nbr, piv, braced, black, req, forb, mark, markList, rings, ringOf,
gateCell: g.gate[0] * W + g.gate[1], gateDir: DIX[g.gate[2]], endCell: g.end ? g.end[0] * W + g.end[1] : -1,
intendedK: g.intended.map(keyOf),
parK: g.par_moves ? g.par_moves.map(ms => { let k = g.gate[0] * W + g.gate[1]; const R = [k]; for (const ch of ms) { const d = DIX[ch]; k += DR[d] * W + DC[d]; R.push(k); } return R; })
: [g.intended.map(keyOf)] };
}
const rc = k => [Math.floor(k / G.W), k % G.W];
const key = (r, c) => r * G.W + c;
function isSand(r, c) { return r >= 0 && r < G.H && c >= 0 && c < G.W && G.sand[key(r, c)] === 1; }
function dirTo(v, w) { for (let d = 0; d < 4; d++) if (G.nbr[v * 4 + d] === w) return d; return -1; }
function dirBetween(a, b) { const d = dirTo(a, b); return d < 0 ? null : DN[d]; }
/* ---------- route state: the same rules serve the player's moves and the abbot's search ---------- */
function newState() {
return { P: new Int16Array(G.N + 1), mv: new Int8Array(G.N + 1), vis: new Uint8Array(G.N), ringCnt: new Int16Array(G.rings.length), len: 0 };
}
function push(S, w) {
S.mv[S.len] = S.len === 0 ? opp(G.gateDir) : dirTo(S.P[S.len - 1], w); // mv[i]: the move INTO P[i] (P[0] is entered through the gate)
S.P[S.len++] = w; S.vis[w] = 1;
for (const k of G.ringOf[w]) S.ringCnt[k]++;
}
function pop(S) { const w = S.P[--S.len]; S.vis[w] = 0; for (const k of G.ringOf[w]) S.ringCnt[k]--; }
function stateOf(P) { const S = newState(); for (const v of P) push(S, v); return S; }
const ctz4 = m => (m & 1) ? 0 : (m & 2) ? 1 : (m & 4) ? 2 : 3;
function reqMsg(v) { const [r, c] = rc(v); return G.rows[r][c] === "." ? "The abbot's groove runs straight on from here." : "Bands are crossed in one straight run, edge to edge."; }
/* Why stepping from the head to w is refused (null = allowed; "" = silently ignored). */
function check(S, w) {
const v = S.P[S.len - 1], e = dirTo(v, w);
if (e < 0) return "";
if (S.vis[w]) return "Never step on your own footprints.";
const m1 = S.mv[S.len - 1], back = opp(m1);
if ((G.forb[v] >> e) & 1) return "Follow the abbot's groove through this cell.";
if (G.req[v] & ~(1 << back) & ~(1 << e)) return reqMsg(v);
if (G.mark[v] === 1 && e !== m1) return "A pale pebble: the line runs straight through it.";
if (G.mark[v] === 2 && e === m1) return "A dark pebble: the line turns at it.";
const bw = opp(e);
if ((G.forb[w] >> bw) & 1) return "The abbot's groove does not run this way.";
const rest = G.req[w] & ~(1 << bw);
if (rest & (rest - 1)) return reqMsg(w);
if (rest) { const n = G.nbr[w * 4 + ctz4(rest)]; if (n < 0 || S.vis[n]) return reqMsg(w); }
if (G.mark[w] === 1) { const n = G.nbr[w * 4 + e]; if (n < 0 || S.vis[n]) return "A pale pebble: the line would have to run straight on through it, and can't."; }
if (G.mark[w] === 2) {
const a = G.nbr[w * 4 + ((e + 1) & 3)], b = G.nbr[w * 4 + ((e + 3) & 3)];
if ((a < 0 || S.vis[a]) && (b < 0 || S.vis[b])) return "A dark pebble: the line would have to turn at it, and can't.";
}
for (let k = 0; k < G.rings.length; k++) {
const c = S.ringCnt[k];
if (c > 0 && c < 8 && !G.ringOf[w].includes(k)) return "Finish the ring around the ripple stone first.";
}
if (G.wide && S.len >= 2) {
const m2 = S.mv[S.len - 2];
if (e === opp(m2) && m1 !== m2) {
const u = S.P[S.len - 2];
if (!(G.piv[u * 4 + m2] && G.piv[v * 4 + m2]))
return [u, v].some(k => { const [r, c] = rc(k); return sym(G, r + DR[m2], c + DC[m2]) === "R"; })
? "The wide rake can't turn back against the abbot's finished sand (it would rake it): a U-turn needs a wall or a stone to pivot on."
: "The wide rake can't turn back in open sand: a U-turn needs a wall or a stone to pivot on.";
}
}
return null;
}
function canEnd(v) { return !G.mark[v] && (G.endCell < 0 || v === G.endCell) && (!G.wide || G.braced[v] === 1); }
/* Why a route covering every cell can't be raked from where it ends (null = it can). */
function finishCheck(S) {
const v = S.P[S.len - 1], back = opp(S.mv[S.len - 1]);
if (G.req[v] & ~(1 << back)) return "The walk can't end here: this cell's groove continues.";
if (G.mark[v]) return "The walk can't end on a pebble: the line has to pass through it.";
if (G.endCell >= 0 && v !== G.endCell) return "The walk has to end at the side gate, where the stroke begins.";
if (G.wide && !G.braced[v]) return "Set the wide rake down against a stone or a wall.";
return null;
}
/* ---------- the abbot's search: can this route still be finished? ---------- */
const BQ = new Int16Array(256), BSEEN = new Uint8Array(256);
function reachable(S) { // unvisited cells reachable from the head through unvisited cells
const head = S.P[S.len - 1]; BSEEN.fill(0, 0, G.N);
let qh = 0, qt = 0, n = 0; BQ[qt++] = head; BSEEN[head] = 1;
while (qh < qt) {
const v = BQ[qh++];
for (let d = 0; d < 4; d++) { const w = G.nbr[v * 4 + d]; if (w >= 0 && !S.vis[w] && !BSEEN[w]) { BSEEN[w] = 1; BQ[qt++] = w; n++; } }
}
return n;
}
function freeNbrs(S, v, head) { let f = 0; for (let d = 0; d < 4; d++) { const w = G.nbr[v * 4 + d]; if (w >= 0 && (!S.vis[w] || w === head)) f++; } return f; }
function feasible(S) { // necessary conditions only (the same as the offline solver's pruning)
const head = S.P[S.len - 1], rem = G.nSand - S.len;
if (G.endCell >= 0 && S.vis[G.endCell]) return false;
if (reachable(S) !== rem) return false;
let dead = 0, nb = 0, anyEnd = false;
for (const v of G.sandList) {
if (S.vis[v]) continue;
const f = freeNbrs(S, v, head);
if (f === 0) return false;
if (f === 1) { if (++dead > 1 || !canEnd(v)) return false; }
if (G.black[v]) nb++;
if (!anyEnd && canEnd(v)) anyEnd = true;
}
if (!anyEnd) return false;
if (nb !== (G.black[head] ? Math.floor(rem / 2) : Math.ceil(rem / 2))) return false;
for (const v of G.markList) {
if (S.vis[v]) continue;
const u = d => { const w = G.nbr[v * 4 + d]; return w >= 0 && (!S.vis[w] || w === head); };
if (G.mark[v] === 1 ? !((u(0) && u(2)) || (u(1) && u(3))) : !((u(0) || u(2)) && (u(1) || u(3)))) return false;
}
return true;
}
/* Depth-first search for one completion. Returns true (S then holds a full raking), false (proved impossible),
or null (gave up after `budget` nodes: the abbot never claims impossibility without proof). */
/* `dead` remembers positions already proved to have no completion. A position is the footprints, the head, and the
last two moves: the rules never look further back, so a position reached again by another route is just as dead.
The keys are exact (no hashing), so a remembered failure is a proof, not a guess. */
function posKey(S) {
let k = S.P[S.len - 1] + "," + S.mv[S.len - 1] + "," + (S.len >= 2 ? S.mv[S.len - 2] : 9);
for (let i = 0; i < G.N; i += 30) { let x = 0; const e = Math.min(G.N, i + 30); for (let j = i; j < e; j++) if (S.vis[j]) x |= 1 << (j - i); k += "," + x; }
return k;
}
function complete(S, budget, dead = new Set()) {
let nodes = 0;
const rec = () => {
if (++nodes > budget) throw complete;
if (S.len === G.nSand) return finishCheck(S) === null;
const pk = posKey(S);
if (dead.has(pk)) return false;
if (feasible(S)) {
const v = S.P[S.len - 1];
for (let d = 0; d < 4; d++) {
const w = G.nbr[v * 4 + d];
if (w < 0 || S.vis[w] || check(S, w) !== null) continue;
push(S, w);
if (rec()) return true;
pop(S);
}
}
dead.add(pk);
return false;
};
try { return rec(); } catch (x) { if (x === complete) return null; throw x; }
}
function analyze(P, budget = 250000) {
const n = P.length;
// Par: a route can still reach par iff it is a prefix of one of the garden's par rakings (all are precomputed, by proof).
let lcp = 0, R = null;
for (const Rk of G.parK) {
let k = 0; while (k < n && k < Rk.length && Rk[k] === P[k]) k++;
if (k > lcp || (k === lcp && Rk === G.parK[0])) { lcp = k; R = Rk; }
}
if (lcp === n) return { state: "par", lcp, R };
const S = stateOf(P), dead = new Set();
const ok = complete(S, budget, dead);
if (ok === true) return { state: "finish", lcp, completion: Array.from(S.P.slice(0, S.len)) };
if (ok === null) return { state: "unknown", lcp };
// Stuck: say why, and find the nearest footprint from which the garden can still be finished. A completion of a
// longer route completes all its shorter prefixes, so "can be finished" only switches once, from yes to no, along the
// route: binary search between the par prefix (yes) and the whole route (no, just proved). The answer is proven
// nearest when the footprint after it is proved impossible (then so is every later one).
const why = diagnose(P);
let lo = lcp, hi = n, hiProved = true;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1, r = complete(stateOf(P.slice(0, mid)), Math.ceil(budget / 2), dead);
if (r === true) lo = mid;
else { hi = mid; hiProved = r === false; }
}
return { state: "stuck", lcp, back: lo, proven: hiProved, why };
}
function diagnose(P) {
const S = stateOf(P), head = P[P.length - 1], rem = G.nSand - S.len;
if (!rem) return { text: finishCheck(S) || "The walk can't end here.", cells: [head] };
reachable(S);
const cutOff = G.sandList.filter(v => !S.vis[v] && !BSEEN[v]);
if (cutOff.length) return { text: `${cutOff.length === 1 ? "One cell of sand is" : cutOff.length + " cells of sand are"} cut off: the walk can't reach ${cutOff.length === 1 ? "it" : "them"} any more.`, cells: cutOff };
if (G.endCell >= 0 && S.vis[G.endCell]) return { text: "You have walked through the side gate's cell, where the walk has to end.", cells: [G.endCell] };
const dead = G.sandList.filter(v => !S.vis[v] && freeNbrs(S, v, head) === 1);
if (dead.length > 1) return { text: `${dead.length} cells now have only one way in, but the walk can end in only one of them.`, cells: dead };
if (dead.length === 1 && !canEnd(dead[0])) {
const v = dead[0], rule = G.endCell >= 0 && v !== G.endCell ? "the walk must end at the side gate"
: G.mark[v] ? "the line has to pass through a pebble" : "the wide rake must be set down against a stone or a wall";
return { text: `This cell has only one way in, so the walk would have to end there, and it can't: ${rule}.`, cells: dead };
}
let nb = 0; for (const v of G.sandList) if (!S.vis[v] && G.black[v]) nb++;
if (nb !== (G.black[head] ? Math.floor(rem / 2) : Math.ceil(rem / 2)))
return { text: "Count the dark and light cells still to cover: a walk alternates colours, and from here one colour has too many, so a cell would be stranded.", cells: [], checker: true };
return { text: "Every way on from here runs into a dead end somewhere; the abbot has checked them all.", cells: [] };
}
/* ---------- loading a garden ---------- */
/* ---------- the daily garden: one garden for everyone, picked by the local date from a year of them, raked once ---------- */
let todayOverride = null; // a test hook sets a date
const pad2 = n => String(n).padStart(2, "0");
function todayDate() { return todayOverride ? new Date(todayOverride) : new Date(); }
function dateKey(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
function dayNumber(d) { return Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - Date.UTC(2026, 0, 1)) / 86400000); }
function ensureDaily(force = false) { // the daily slot (after the campaign) holds today's garden; returns its index, or -1
if (!DAILY.length) return -1;
const d = todayDate(), k = dateKey(d), n = dayNumber(d);
const onScreen = GARDENS.length > NCAMP && view === "puzzle" && idx === NCAMP; // past midnight, the garden being played stays until it is left (so does a shared raking of another day's)
if (GARDENS.length === NCAMP || (GARDENS[NCAMP].dateKey !== k && (force || !onScreen))) GARDENS[NCAMP] = dailyFor(n);
return NCAMP;
}
function dailyStreak() { // consecutive days raked, ending today (or yesterday, if today is not yet raked)
let n = 0; const d = todayDate();
if (!(progress["daily:" + dateKey(d)] || {}).done) d.setDate(d.getDate() - 1);
while ((progress["daily:" + dateKey(d)] || {}).done) { n++; d.setDate(d.getDate() - 1); }
return n;
}
function dateLabel(k) { const [y, m, d] = k.split("-").map(Number); return new Date(y, m - 1, d).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }); }
const isDaily = () => !!(GARDENS[idx] && GARDENS[idx].daily);
function dailyReady() { return isDaily() && mode === "draw" && path.length === G.nSand && !finishCheck(stateOf(path)); }
function dailyActions(g, p) {
shareAction(g, p.kind);
addAction("Read the poem again", () => { const box = el("poem"); box.hidden = false; fillPoem(g.poem, box); void box.offsetWidth; box.classList.add("on"); el("tick").hidden = true; }, "Lay the poem over the garden again");
}
function pickDaily() { const i = ensureDaily(true); if (i >= 0) { load(i); showStage(); } }
function load(i) {
cancelPoem();
stopDemo(false); if (mode === "demo" || (mode === "reveal" && REV && REV.demo)) { mode = "draw"; REV = null; }
view = "puzzle"; VIEW_EL.classList.remove("practice", "poems");
idx = i;
const g = GARDENS[i];
try { if (!g.daily) localStorage.setItem("samon-last", String(i)); } catch (e) {}
G = buildGarden(g);
if (shared) { shared = null; dropHash(); } // (a shared raking left for another garden is put away, link and all)
path = []; mode = "draw"; cut = null; ghosts = new Map(); stopAnim(); clearAdvice();
const draft = (progress[g.name] || {}).draft;
if (Array.isArray(draft) && draft.length > 1 && draft[0] === G.gateCell) {
const S = newState(); push(S, draft[0]); let ok = true;
for (let j = 1; j < draft.length && ok; j++) { if (check(S, draft[j]) === null) push(S, draft[j]); else ok = false; }
if (ok) path = draft.slice();
}
helped = !!(progress[g.name] || {}).helped;
obs = []; rakedHelped = false; cutHelped = false;
pzBuild();
el("seal").hidden = true; el("seal").classList.remove("grey"); el("next").hidden = true; el("restore").hidden = true;
el("next").textContent = g.daily ? "Back to the gardens" : "Next garden";
const inPart = GARDENS.filter(x => x.world === g.world && !!x.extra === !!g.extra), pos = inPart.indexOf(g) + 1;
const streak = g.daily ? dailyStreak() : 0;
el("time").textContent = g.daily ? `Daily garden${streak ? ` · streak ${streak}` : ""}` : `${TIMES[g.world]} · ${g.extra ? "more, " : ""}${pos} of ${inPart.length}`;
el("where").title = g.daily ? "Today's garden: one for everyone, from a year of them, raked once; the streak counts the days in a row it has been raked"
: `${TIMES[g.world]}, the ${["", "first", "second", "third", "fourth", "fifth", "sixth", "seventh"][g.world]} of seven worlds, each a time of day: ${g.extra ? `an extra garden of this world (${pos} of ${inPart.length})` : `garden ${pos} of ${inPart.length} on its main path`}. A world opens when two-thirds of the one before is raked.`;
el("next").title = g.daily ? "Back to the gardens (Enter)" : "On to the next garden (Enter)";
el("gname").textContent = g.daily ? dateLabel(g.dateKey) : g.name;
el("par").textContent = g.par;
const facts = g.rakings === 1 ? "exactly one valid raking exists" :
`${g.rakings.toLocaleString("en-US")} valid rakings exist; ${g.at_par === g.rakings ? "all reach par" : `${g.at_par.toLocaleString("en-US")} reach par`}`;
el("par").parentElement.title = `Par ${g.par}: the fewest turns any raking of this ${G.H}×${G.W} garden can have, proved by the solver (${facts}; a valid raking is a walk in from the gate that covers every cell of sand once and may end where it does). Rake at par for the abbot's seal.`;
const dailyRule = g.daily ? "Today's garden, the same for everyone. Draw and undo freely; nothing is raked until you press Rake, and that first raking is the day's record. " : "";
const rule = (dailyRule ? `[${escapeHtml(dailyRule.trim())}] ` : "") + (g.intro ? `[${escapeHtml(g.intro)}]` : ruleSummary(g)); // (only where the garden brings in a rule: a plain garden's rule is the game's)
el("rule").innerHTML = rule; el("rule").hidden = !rule;
setStatus(path.length ? "Your unfinished walk is where you left it." : "Drag the monk in from the gate.");
cv.setAttribute("aria-label", `${g.daily ? "Today's garden" : g.name}: a ${G.H} by ${G.W} garden. Drag from the gate to walk in.`);
const rec = progress[g.name] || {};
if (g.daily && rec.done && Array.isArray(rec.path) && rec.path.length === G.nSand) { // raked already today: show the record
path = rec.path.slice(); mode = "done"; doneAt = 0; rakedHelped = false;
const st = strokeOf(path); rakeAlong(PZ, st, 0, st.L); PZ.dirty = null; PZ.feet = null; fieldLight(PZ);
showSeal(rec.kind);
setStatus(`Today's record: ${rec.turns} turns, par ${rec.par}${rec.kind === "red" ? ", the abbot's seal" : rec.kind === "grey" ? ", his seal in grey" : ""}. Streak: ${streak} day${streak === 1 ? "" : "s"}. A new garden comes tomorrow.`);
el("next").hidden = false; dailyActions(g, rec);
}
renderNav(); resize(); checkComplete(false); update();
}
function pickGarden(i) { if (worldOpen(GARDENS[i].world)) { load(i); showStage(); maybeDemo(); } }
/* Each world has a short main path (the template) and optional extras. A world opens once two-thirds of the main path
of the world before it has been raked, at any number of turns, counting extras raked instead: par is never required,
and any garden can be finished by persistence or with the abbot's help, so nobody is ever stuck. */
const UNLOCK = 2 / 3;
function rakedIn(w) { return GARDENS.filter(g => g.world === w && (progress[g.name] || {}).done).length; }
function needIn(w) { return Math.ceil(UNLOCK * GARDENS.filter(g => g.world === w && !g.extra).length); }
function worldOpen(w) { return w <= 1 || openAll || (worldOpen(w - 1) && rakedIn(w - 1) >= needIn(w - 1)); }
const unraked = g => !(progress[g.name] || {}).done;
function nextGarden(i) { // along the main path (or through a world's extras, from an extra); then the first unraked garden
const here = GARDENS[i];
if (here.daily) return firstUnraked();
for (let j = i + 1; j < NCAMP; j++) {
const g = GARDENS[j];
if (!worldOpen(g.world)) break;
if (here.extra ? (g.extra && g.world === here.world) : !g.extra) return j;
}
return firstUnraked();
}
function firstUnraked() {
let k = GARDENS.findIndex(g => !g.daily && worldOpen(g.world) && !g.extra && unraked(g));
if (k < 0) k = GARDENS.findIndex(g => !g.daily && worldOpen(g.world) && unraked(g));
return k >= 0 ? k : 0;
}
function ruleSummary(g) {
const parts = [];
if (g.wide) parts.push("Wide rake: a U-turn needs a wall or a stone to pivot on, and the walk must end against one.");
if (g.end) parts.push("Begin at the side gate.");
if (g.marks && Object.keys(g.marks).length) parts.push("Pale pebble: straight through. Dark pebble: turn.");
if (g.rows.some(r => /[=|]/.test(r))) parts.push("Rake each block across or down, as hatched.");
if (g.rows.some(r => r.includes("O"))) parts.push("Ripple stones: rake each ring in one unbroken run.");
if (g.rows.some(r => /[hv]/.test(r))) parts.push("Cross each band in one straight run.");
if (g.rows.some(r => r.includes("R"))) parts.push("The abbot's finished ring is not to be touched.");
return parts.join(" "); // (empty for a plain garden: one stroke, every cell once, ending at the gate, which the help says)
}
function showStage() { // picking a garden from the list at the bottom brings the garden itself into view
const r = cv.getBoundingClientRect();
if (r.top < 0 || r.top > window.innerHeight * 0.6) cv.scrollIntoView({ block: "start", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
}
function escapeHtml(s) { return s.replace(/[&<>"]/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch])); }
function turnsOf(P = path) {
if (!P.length) return 0;
let t = 0, prev = opp(G.gateDir);
for (let i = 1; i < P.length; i++) { const d = dirTo(P[i - 1], P[i]); if (d !== prev) t++; prev = d; }
// with a side gate, a complete walk ends at its cell, and the stroke enters there through the wall: a bend there is a turn
if (G.endCell >= 0 && G.end_side && P.length === G.nSand && P[P.length - 1] === G.endCell && P.length > 1 && prev !== DIX[G.end_side]) t++;
return t;
}
function backDir(i, P = path) { return i === 0 ? G.gate[2] : dirBetween(P[i], P[i - 1]); }
/* ---------- interaction ---------- */
function cellAt(px, py) {
const { ox, oy, cs, wall } = geom;
const c = Math.floor((px - ox) / cs), r = Math.floor((py - oy) / cs);
if (r < 0 || r >= G.H || c < 0 || c >= G.W) {
// a touch on the gate's gap in the wall counts as the gate cell
const [gr, gc, side] = G.gate; const x = ox + gc * cs, y = oy + gr * cs;
const inGap = side === "S" ? (px >= x && px < x + cs && py >= y + cs && py < y + cs + wall)
: side === "N" ? (px >= x && px < x + cs && py < y && py >= y - wall)
: side === "W" ? (py >= y && py < y + cs && px < x && px >= x - wall)
: (py >= y && py < y + cs && px >= x + cs && px < x + cs + wall);
return inGap ? G.gateCell : -1;
}
return key(r, c);
}
function routeChanged(kind) { // kind: "start" (first footprint), "step", "back", or "cut" (a rewind or restart)
clearAdvice();
if (path.length) { demoOnce = false; if (!started) { started = true; try { localStorage.setItem("samon-started", "1"); } catch (e) {} } }
if (kind === "step") {
const head = path[path.length - 1];
if (ghosts.has(head)) ghosts.delete(head); else ghosts.clear(); // following the abbot's sketch uses it up; leaving it erases it
cut = null;
} else if (kind !== "start") ghosts.clear();
const g = GARDENS[idx]; const p = progress[g.name] || {}; p.draft = path.slice(); progress[g.name] = p; saveProgressSoon();
if (PZ) pzWalk();
checkComplete();
update();
}
let saveTimer = null;
function saveProgressSoon() { clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = null; saveProgress(); }, 300); }
function saveProgressNow() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; saveProgress(); } } // (a page closed within the debounce would lose the last footprints: the fuzzer found it)
window.addEventListener("pagehide", saveProgressNow);
document.addEventListener("visibilitychange", () => { if (document.hidden) saveProgressNow(); });
function stepTo(w) { // one step of the walk to a neighbouring cell (or back off the last one); true if the walk changed
if (mode !== "draw" || w < 0 || !path.length) return false;
const head = path[path.length - 1];
if (w === head) return false;
if (path.length >= 2 && w === path[path.length - 2]) { path.pop(); setStatus(""); crunch(0.08); haptic(6); routeChanged("back"); return true; }
if (dirTo(head, w) < 0) return false;
const why = check(stateOf(path), w);
if (why === null) { path.push(w); setStatus(""); crunch(0.11); haptic(8); routeChanged("step"); return true; }
if (why) setStatus(why, true);
return false;
}
function setHelped(v) { // help marks the garden until it is next raked (and travels with a helped route)
helped = v;
const p = progress[GARDENS[idx].name] || {};
if (v) p.helped = true; else delete p.helped;
progress[GARDENS[idx].name] = p; saveProgressSoon();
}
function rewindTo(k, note = "") { // keep the first k footprints; the rest can be restored. note: what is true from here, for the status line
if (k >= path.length) return;
const n = path.length - k;
if (n >= 2) { cut = path.slice(); cutHelped = helped; }
path = path.slice(0, k);
setStatus(`Stepped back ${n} footprint${n === 1 ? "" : "s"}${note ? `: ${note}` : ""}.${n >= 2 ? " Restore brings them back." : ""}`);
routeChanged("cut");
}
/* Completing the walk is raking: when the finger lifts from a walk that covers every cell (and may end where it does),
the monk rakes. From the keyboard there is a longer beat, in which Backspace can still take the last step back. */
function checkComplete(arm = true) {
clearTimeout(rakeTimer); rakeTimer = null;
if (mode !== "draw" || path.length !== G.nSand) return;
const why = finishCheck(stateOf(path));
if (why) { setStatus(why, true); return; }
if (isDaily()) { setStatus("Every cell is covered: press Rake, and this raking is today's record."); return; }
if (arm) armRake(450); else setStatus("Every cell is covered: tap the monk to rake.");
}
function armRake(delay) {
clearTimeout(rakeTimer); rakeTimer = null;
if (view !== "puzzle" || mode !== "draw" || dragging || isDaily() || path.length !== G.nSand || finishCheck(stateOf(path))) return;
rakeTimer = setTimeout(() => { rakeTimer = null; rakeNow(); }, delay);
}
function reopen(k) { // after raking: smooth the sand and go back to drawing, keeping the first k footprints
if (mode !== "done" || k < 1) return;
if (shared) { leaveShared(); return; } // (another player's raking is not walked back into: the garden is simply yours again)
if (isDaily()) { setStatus("Today's raking is made; a new garden comes tomorrow.", true); return; }
cancelPoem();
stopAnim(); mode = "draw";
el("seal").hidden = true; el("seal").classList.remove("grey"); el("next").hidden = true;
cut = path.slice(); cutHelped = rakedHelped; path = path.slice(0, k);
// walking back into a raking made with help keeps the help: re-raking the same route can't wash the grey out
// (walking back all the way to the gate is a fresh start, as Start over is)
if (rakedHelped && k > 1) setHelped(true);
if (PZ) PZ.feet = null; // the raked grooves go: the whole field is redone from the unraked sand
setStatus(rakedHelped && k > 1 ? "Smoothed back to your footprints; the abbot's help still counts on this walk. Restore rakes it again as it was."
: "Smoothed back to your footprints; Restore rakes it again as it was.");
routeChanged("cut");
}
function pointerCell(ev) {
const rect = cv.getBoundingClientRect();
return [(ev.clientX - rect.left) * (geom.w / rect.width), (ev.clientY - rect.top) * (geom.h / rect.height)];
}
/* Dragging. The walk follows the pointer along the axis the hand is moving on, not the cell the pointer happens to be in:
a hand that drifts off the row while sweeping along it (arms are not mounted on tracks) still draws a straight run, and
the walk turns only when the motion itself turns, to within DRAG_SNAP degrees of the other axis, or when the pointer has
gone two cells off the run, which is no drift. The direction is read over the last DRAG_WIN cells of motion, so a jitter
in place reads as nothing. The pointer is captured, so the grip survives leaving the field (and the window): a pointer
beyond the walls is read as the nearest cell. */
const DRAG = { trail: [], axis: null };
const DRAG_WIN = 0.4, DRAG_SNAP = 30;
function dragStart(x, y) { DRAG.trail = [{ x, y, s: 0 }]; DRAG.axis = null; }
function dragDir(x, y) { // the vector of the last DRAG_WIN cells of motion, or null while there has not been that much
const T = DRAG.trail, last = T[T.length - 1], s = last.s + Math.hypot(x - last.x, y - last.y), back = s - DRAG_WIN * geom.cs;
T.push({ x, y, s });
while (T.length > 1 && T[1].s <= back) T.shift();
const p = T[0]; if (p.s > back) return null;
const dx = x - p.x, dy = y - p.y;
return Math.hypot(dx, dy) < 0.6 * DRAG_WIN * geom.cs ? null : [dx, dy]; // (a short net distance over that much path is a wobble)
}
function dragAxis(v) { // the axis of motion, with hysteresis: the current one holds until the motion is clearly on the other
const th = Math.atan2(Math.abs(v[1]), Math.abs(v[0])) * 180 / Math.PI; // 0 = horizontal, 90 = vertical
if (DRAG.axis === "H") return th > 90 - DRAG_SNAP ? "V" : "H";
if (DRAG.axis === "V") return th < DRAG_SNAP ? "H" : "V";
return th < 45 ? "H" : "V";
}
function clampField(x, y) {
const { ox, oy, cs } = geom;
return [Math.min(Math.max(x, ox), ox + G.W * cs - 0.01), Math.min(Math.max(y, oy), oy + G.H * cs - 0.01)];
}
function dragTo(x, y) { // the pointer is at (x, y) (canvas pixels, possibly beyond the field): walk towards it
const v = dragDir(x, y);
if (v) { const ax = dragAxis(v); if (DRAG.axis && ax !== DRAG.axis) settle(DRAG.axis, x, y); DRAG.axis = ax; }
if (!DRAG.axis || !path.length || mode !== "draw") return;
const [pr, pc] = rc(cellAt(...clampField(x, y)));
for (let n = 0; n < G.W + G.H; n++) {
const [hr, hc] = rc(path[path.length - 1]);
let ax = DRAG.axis;
if (ax === "H" && Math.abs(pr - hr) >= 2) ax = "V"; else if (ax === "V" && Math.abs(pc - hc) >= 2) ax = "H";
const nr = ax === "V" ? hr + Math.sign(pr - hr) : hr, nc = ax === "H" ? hc + Math.sign(pc - hc) : hc;
if ((nr === hr && nc === hc) || !stepTo(key(nr, nc))) break;
}
}
function settle(ax, x, y) { // the motion has turned: first finish what it was doing along the old axis, as far as the pointer went
if (!path.length || mode !== "draw") return;
const [pr, pc] = rc(cellAt(...clampField(x, y)));
for (let n = 0; n < G.W + G.H; n++) {
const [hr, hc] = rc(path[path.length - 1]);
const nr = ax === "V" ? hr + Math.sign(pr - hr) : hr, nc = ax === "H" ? hc + Math.sign(pc - hc) : hc;
if ((nr === hr && nc === hc) || !stepTo(key(nr, nc))) break;
}
}
cv.addEventListener("pointerdown", ev => {
audio();
if (view === "practice") { prDown(ev); return; }
clearTimeout(rakeTimer); rakeTimer = null;
if (demoRun || mode === "demo" || (mode === "reveal" && REV && REV.demo)) { stopDemo(true); return; }
if (mode === "reveal") { finishAnimNow(); return; }
if (mode === "done") {
if (performance.now() - doneAt < 600) return; // a second tap meant to skip the raking is not a step back
if (poemRun && poemRun.shown && !poemRun.stay) { stayPoem(false); return; } // (the walls around the poem: put it away and stay)
if (shared) { leaveShared(); return; }
const [x, y] = pointerCell(ev), j = path.indexOf(cellAt(x, y));
if (j < 0) return;
dragging = true; reopen(j + 1); dragStart(x, y); cv.setPointerCapture(ev.pointerId); return;
}
const [x, y] = pointerCell(ev); const k = cellAt(x, y);
if (k < 0 && !path.length) return;
if (k === G.gateCell) { const fresh = !path.length; if (path.length > 1) { cut = path.slice(); cutHelped = helped; } path = [G.gateCell]; dragging = true; crunch(0.1); haptic(8); routeChanged(fresh ? "start" : "cut"); }
else if (path.includes(k)) { const j = path.indexOf(k); if (j < path.length - 1) rewindTo(j + 1); dragging = true; }
else if (path.length && k >= 0 && dirTo(path[path.length - 1], k) >= 0) { dragging = true; stepTo(k); }
else if (!path.length) { setStatus("Start at the gate: drag the monk in from there.", true); demoOnce = true; draw(); return; }
else return;
dragStart(x, y); cv.setPointerCapture(ev.pointerId);
});
cv.addEventListener("pointermove", ev => {
if (view === "practice") { prMove(ev); return; }
if (!dragging) return;
dragTo(...pointerCell(ev));
});
const endDrag = () => { const was = dragging; dragging = false; if (view === "practice") prUp(); else if (was) armRake(250); };
cv.addEventListener("pointerup", endDrag); cv.addEventListener("pointercancel", endDrag);
cv.addEventListener("keydown", ev => {
audio();
if (view !== "puzzle") return;
if (demoRun || mode === "demo" || (mode === "reveal" && REV && REV.demo)) { ev.preventDefault(); stopDemo(true); return; }
const map = { ArrowUp: "N", ArrowDown: "S", ArrowLeft: "W", ArrowRight: "E" };
if (map[ev.key]) {
ev.preventDefault();
if (mode !== "draw") return;
if (!path.length) { path = [G.gateCell]; routeChanged("start"); return; }
const [r, c] = rc(path[path.length - 1]); const [a, b] = DIRS[map[ev.key]];
if (r + a >= 0 && r + a < G.H && c + b >= 0 && c + b < G.W) stepTo(key(r + a, c + b));
} else if (ev.key === "Backspace") { ev.preventDefault(); undo(); }
else if (ev.key === "Enter") {
if (mode === "reveal") finishAnimNow();
else if (mode === "done") advance(true);
else if (rakeTimer) { clearTimeout(rakeTimer); rakeTimer = null; rakeNow(); }
else if (dailyReady()) rakeNow();
}
else if (ev.key === "Escape" && poemRun && !poemRun.stay) stayPoem(false);
else if (ev.key === "r" || ev.key === "R") restart();
else if (ev.key === "h" || ev.key === "H") askAbbot();
});
function undo() {
if (mode === "done") { reopen(path.length - 1); return; }
if (mode === "draw" && path.length > 1) { path.pop(); setStatus(""); routeChanged("back"); } else if (mode === "draw" && path.length) restart();
}
function restart() {
if (demoRun || mode === "demo" || (mode === "reveal" && REV && REV.demo)) { stopDemo(true); return; }
if (shared) { leaveShared(); return; }
if (mode === "done" && isDaily()) { setStatus("Today's raking is made; a new garden comes tomorrow.", true); return; }
cancelPoem();
stopAnim();
if (mode !== "reveal" && path.length > 1) { cut = path.slice(); cutHelped = mode === "done" ? rakedHelped : helped; }
mode = "draw"; path = []; el("seal").hidden = true; el("seal").classList.remove("grey"); el("next").hidden = true;
setStatus("Drag the monk in from the gate."); routeChanged("cut");
}
function restore() {
if (!cut || mode !== "draw") return;
if (cutHelped && !helped) setHelped(true); // a route made with help brings its help back with it
path = cut; cut = null; setStatus("Restored."); routeChanged("cut");
}
el("undo").onclick = undo; el("restart").onclick = restart; el("restore").onclick = restore;
el("next").onclick = () => { if (mode === "done") advance(true); else { load(nextGarden(idx)); maybeDemo(); } };
el("hint").onclick = () => askAbbot();
el("rakeDay").onclick = () => { if (dailyReady()) rakeNow(); };
function rakeNow() {
if (view !== "puzzle" || mode !== "draw" || path.length !== G.nSand || finishCheck(stateOf(path))) return;
clearTimeout(rakeTimer); rakeTimer = null; startReveal();
}
/* ---------- the abbot's hint: about your route, not his ---------- */
function clearAdvice() { el("actions").innerHTML = ""; marks = { cells: [], target: -1, checker: false }; hintPresses = 0; }
function showSeal(kind) { // the abbot's seal (or none), with what it says
const sl = el("seal"); sl.hidden = !kind; sl.classList.toggle("grey", kind === "grey");
const say = kind === "grey" ? "The abbot's seal, in grey: raked at par, with his help" : "The abbot's seal (静, “stillness”): raked at par";
sl.setAttribute("aria-label", say); sl.title = say; return sl;
}
function addAction(label, fn, title = "") {
const b = document.createElement("button"); b.type = "button"; b.className = "act"; b.textContent = label; if (title) b.title = title;
b.onclick = () => { fn(); if (view !== "poems") cv.focus({ preventScroll: true }); }; // (the button may be gone after; the keys should still walk the monk)
el("actions").appendChild(b);
}
function sketch(R, from) { // sketch the groove of R at index `from` (the next cell of a raking that fits your route)
if (from >= R.length) return false;
const k = R[from], ds = new Set();
ds.add(from === 0 ? G.gate[2] : dirBetween(k, R[from - 1]));
if (from + 1 < R.length) ds.add(dirBetween(k, R[from + 1]));
ghosts.set(k, ds); return true;
}
let abbotBudget = 250000; // search nodes per question (a test hook can lower it)
function askAbbot() {
if (view !== "puzzle" || mode !== "draw") return;
const g = GARDENS[idx];
hintPresses++;
if (path.length <= 1 && hintPresses === 1 && g.hint) {
obs = (g.hint_cells || []).map(([r, c]) => key(r, c));
setStatus(`The abbot: “${g.hint}”${obs.length ? " (He points.)" : ""} Ask again and he'll sketch a groove.`); draw(); return;
}
const P = path.length ? path : [G.gateCell];
const a = analyze(P, abbotBudget);
el("actions").innerHTML = ""; marks = { cells: [], target: -1, checker: false };
const steps = n => `${n} footprint${n === 1 ? "" : "s"}`;
if (a.state === "unknown") { // no answer, no charge
setStatus("The abbot can't be sure yet: too many ways on to check quickly. That costs you nothing."); draw(); return;
}
const note = helped ? "" : " (With his help, this raking can earn only a grey seal.)";
const charge = () => { if (!helped) setHelped(true); };
// a walk that has already stopped (no step on is allowed, or every cell is covered but it can't end there): saying why
// is free, since it can't guide a search; where to step back to is help
const S = stateOf(P), head = P[P.length - 1];
const noMove = P.length < G.nSand && ![0, 1, 2, 3].some(d => { const w = G.nbr[head * 4 + d]; return w >= 0 && !S.vis[w] && check(S, w) === null; });
if (a.state === "stuck" && (noMove || (P.length === G.nSand && finishCheck(S)))) {
marks.cells = a.why.cells; marks.checker = !!a.why.checker;
setStatus(`${a.why.text} (Saying why a walk has stopped is free; where to step back to counts as his help.)`, true);
addAction("Where can I step back to?", () => { // (this is help: it marks the footprint the garden can still be finished from)
charge(); const n = P.length - a.back; el("actions").innerHTML = ""; marks.target = P[a.back - 1];
setStatus(`Step back ${steps(n)} to the marked footprint: the garden can still be finished from there${a.proven ? "" : " (perhaps from a nearer one too)"}.${note}`, true);
addAction(`Step back ${n}`, () => rewindTo(a.back, "the garden can be finished from here"), "Step back to the marked footprint; Restore brings the steps back"); draw();
}, "Counts as the abbot's help: his seal for this raking can then only be grey");
draw(); return;
}
charge();
if (a.state === "par") {
let j = P.length; while (j < a.R.length && ghosts.has(a.R[j])) j++;
const more = sketch(a.R, j);
setStatus(`Your walk can still be raked at par.${more ? " The abbot sketches the next groove, faintly." : ""}${note}`);
} else if (a.state === "finish") {
const n = P.length - a.lcp;
marks.target = P[a.lcp - 1];
setStatus(`Your walk can still be finished, but no longer at par: step back ${steps(n)} to the marked footprint for par.${note}`);
addAction(`Step back ${n} for par`, () => rewindTo(a.lcp, "par is still possible from here"), "Step back to the marked footprint, from which par is still possible; Restore brings the steps back");
addAction("Show a way on", () => { let j = P.length; while (j < a.completion.length && ghosts.has(a.completion[j])) j++; sketch(a.completion, j); setStatus("The abbot sketches a groove that still finishes the garden."); draw(); },
"The abbot sketches, faintly, the next groove of a walk that still finishes the garden (not at par)");
} else if (a.state === "stuck") {
const n = P.length - a.back;
marks.cells = a.why.cells; marks.checker = !!a.why.checker; marks.target = P[a.back - 1];
setStatus(`${a.why.text} Step back ${steps(n)} to the marked footprint: the garden can still be finished from there${a.proven ? "" : " (perhaps from a nearer one too)"}.${note}`, true);
addAction(`Step back ${n}`, () => rewindTo(a.back, "the garden can be finished from here"));
if (a.lcp < a.back) addAction(`Step back ${P.length - a.lcp} for par`, () => rewindTo(a.lcp, "par is still possible from here"));
}
draw();
}
/* ---------- the demonstration: when a garden brings in a new rule, the monk first rakes it the calm way (walking in,
then raking out), and then the garden is reset for the player. It plays once by itself; Watch replays it. A tap or a
key skips it. It is free: it doesn't touch the seal. ---------- */
let demoRun = null, demoWatch = null;
function maybeDemo() { // once the garden is in view (on a long page it may not be yet)
const g = GARDENS[idx], want = idx;
if (!(g.demo && !path.length && !(progress[g.name] || {}).demoSeen && view === "puzzle")) return;
const go = () => { demoRun = { timer: setTimeout(() => { demoRun = null; startDemo(); }, 600) }; };
if (!("IntersectionObserver" in window)) { go(); return; }
if (demoWatch) demoWatch.disconnect();
demoWatch = new IntersectionObserver(es => {
if (!es.some(e => e.isIntersecting)) return;
demoWatch.disconnect(); demoWatch = null;
if (idx === want && view === "puzzle" && mode === "draw" && !path.length && !demoRun) go();
}, { threshold: 0.6 });
demoWatch.observe(cv);
}
function startDemo() {
const g = GARDENS[idx];
if (view !== "puzzle" || !g.demo || (mode !== "draw" && mode !== "done")) return;
stopDemo(false); stopAnim(); clearTimeout(rakeTimer); rakeTimer = null; cancelPoem();
mode = "demo"; path = []; ghosts.clear(); clearAdvice(); obs = []; REV = null;
el("seal").hidden = true; el("next").hidden = true;
if (PZ) { PZ.feet = null; pzWalk(true); }
const route = g.intended.map(([r, c]) => key(r, c));
const ms = Math.max(90, Math.min(260, 3200 / route.length));
demoRun = { timer: null };
setStatus("Watch: the monk walks in, then rakes his way back out. (Tap to skip.)");
const step = () => {
if (!demoRun) return;
path.push(route[path.length]); pzWalk(); update(); demoFinger(path[path.length - 1], ms); crunch(0.09);
demoRun.timer = setTimeout(path.length < route.length ? step : () => { if (demoRun) { demoFinger(-1); startReveal(true); } }, path.length < route.length ? ms : 500);
};
step();
}
function demoFinger(k, ms) { // the fingertip that drags the monk along
const f = el("finger");
if (f.anim) { f.anim.cancel(); f.anim = null; }
if (k < 0 || (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches)) { f.style.opacity = 0; f.style.transition = ""; return; }
const [r, c] = rc(k), sc = cv.clientWidth / cv.width;
f.hidden = false; f.style.transition = `transform ${ms}ms linear, opacity 200ms`; f.style.opacity = 0.9;
f.style.transform = `translate(${cv.offsetLeft + (geom.ox + c * geom.cs + geom.cs / 2) * sc}px, ${cv.offsetTop + (geom.oy + r * geom.cs + geom.cs / 2) * sc}px)`;
}
function finishDemo() { // the raked result, a moment to look at it, then the garden is the player's
mode = "demo";
setStatus(`That is a calm way to rake it: ${turnsOf()} turns, which is par. Now you.`);
demoRun = { timer: setTimeout(() => stopDemo(true), 2200) };
}
function stopDemo(reset) {
if (demoWatch) { demoWatch.disconnect(); demoWatch = null; }
if (demoRun && demoRun.timer) clearTimeout(demoRun.timer);
const was = !!demoRun || mode === "demo" || (mode === "reveal" && REV && REV.demo);
demoRun = null; demoFinger(-1);
if (!reset || !was) return;
stopAnim(); REV = null; mode = "draw"; path = [];
if (PZ) { PZ.feet = null; pzWalk(true); }
const g = GARDENS[idx], p = progress[g.name] || {}; p.demoSeen = true; progress[g.name] = p; saveProgress();
el("finger").style.transition = ""; beckonKey = "";
setStatus("Your turn: drag the monk in from the gate.");
update();
}
el("watch").onclick = () => startDemo();
/* ---------- reveal: the monk walks backwards, the rake glides along the stroke ---------- */
let REV = null;
function easeRake(f, a = 0.1) { // constant speed, with a gentle start and stop
const s = f < a ? f * f / (2 * a) : f < 1 - a ? a / 2 + (f - a) : (1 - a) - (1 - f) * (1 - f) / (2 * a);
return s / (1 - a);
}
function startReveal(demo = false) {
mode = "reveal"; setStatus(demo ? "Now he sets down the rake and walks backwards to the gate, raking." : "The monk sets down his rake and walks backwards to the gate.");
clearAdvice(); ghosts.clear();
pzWalk();
const reduce = window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches;
const now = performance.now();
REV = { st: strokeOf(path), s: 0, t0: now, last: now, dur: reduce ? 0 : Math.max(2500, Math.min(6500, 1800 + 75 * path.length)), grains: [], reduce, demo };
if (!REV.dur) { finishAnimNow(); return; }
scrapeStart();
anim = requestAnimationFrame(revealTick);
}
function revealTick(now) {
const f = Math.min(1, (now - REV.t0) / REV.dur), s = REV.st.L * easeRake(f);
if (SND.scr) { // the scrape follows the rake: its speed, and whether the tines are in a bend
const dt = Math.max(0.004, (now - REV.last) / 1000), p = strokePoint(REV.st, s);
scrapeSet((s - REV.s) / dt, REV.st.segs[p.k].type === "arc" ? 1 : 0, p.k >= REV.st.segs.length - 1 ? 0.2 : 0.5); // (out through the gate: the rake lifts)
if (Math.floor(s) > Math.floor(REV.s)) crunch(0.045); // and the monk's backward step, cell by cell
}
rakeAlong(PZ, REV.st, REV.s, s);
REV.s = s;
spawnGrains(now);
fieldFlush(PZ);
draw();
if (f < 1) anim = requestAnimationFrame(revealTick); else { anim = null; finishReveal(); }
}
function spawnGrains(now) { // a few grains flicked aside by the tines
const R = REV, dt = Math.min(0.05, (now - R.last) / 1000); R.last = now;
R.grains = R.grains.filter(g => now - g.t0 < g.life);
if (R.reduce || dt <= 0) return;
const p = strokePoint(R.st, R.s);
if (p.k >= R.st.segs.length - 1) return; // out through the gate
const nx = -p.ty, ny = p.tx;
for (let j = 0; j < 2; j++) {
const side = Math.random() < 0.5 ? -1 : 1, off = side * (RAKE_W / 2) * (0.55 + 0.45 * Math.random()), sp = 0.5 + Math.random() * 0.6;
R.grains.push({ x: p.x + nx * off, y: p.y + ny * off, vx: (nx * side * sp - p.tx * 0.25 + (Math.random() - 0.5) * 0.3), vy: (ny * side * sp - p.ty * 0.25 + (Math.random() - 0.5) * 0.3),
t0: now, life: 260 + Math.random() * 240, r: 0.012 + Math.random() * 0.014 });
}
}
function finishAnimNow() {
stopAnim();
if (REV && REV.s < REV.st.L) { rakeAlong(PZ, REV.st, REV.s, REV.st.L); REV.s = REV.st.L; fieldFlush(PZ); }
finishReveal();
}
function stopAnim() { if (anim) cancelAnimationFrame(anim); anim = null; scrapeStop(); }
function finishReveal() {
scrapeStop();
if (REV && REV.demo) { REV.grains = []; finishDemo(); return; }
mode = "done"; doneAt = performance.now(); if (REV) REV.grains = [];
const t = turnsOf(), g = GARDENS[idx], wasOpen = worldOpen(g.world + 1);
const p = progress[g.name] || {};
p.done = true; p.best = Math.min(p.best ?? 1e9, t); delete p.draft;
const kind = t <= g.par ? (helped ? "grey" : "red") : null;
if (kind === "red") p.sealed = true;
if (kind === "grey") p.grey = true;
rakedHelped = helped; helped = false; delete p.helped;
if (g.daily) { p.path = path.slice(); p.turns = t; p.par = g.par; p.kind = kind; p.day = g.dateKey; }
progress[g.name] = p; saveProgress();
if (kind) {
tok();
const sl = showSeal(kind);
sl.classList.remove("stamp"); void sl.offsetWidth; sl.classList.add("stamp");
}
setStatus(kind === "red" ? `Raked in ${t} turns: par. The abbot's seal.`
: kind === "grey" ? `Raked in ${t} turns: par, with the abbot's help, so his seal is grey. Rake it again on your own for the vermilion one.`
: `Raked in ${t} turns; par is ${g.par}: tap a cell to walk back to it and look for a calmer line.`);
if (g.world < 7 && !wasOpen && worldOpen(g.world + 1))
appendStatus(` ${TIMES[g.world + 1]} is open: ${GARDENS.filter(x => x.world === g.world + 1).length} new gardens.`);
if (g.daily) { const n = dailyStreak(); appendStatus(` Today's record. Streak: ${n} day${n === 1 ? "" : "s"}.`); el("time").textContent = `Daily garden · streak ${n}`; dailyActions(g, p); }
else shareAction(g, kind);
if (g.role === "capstone" && !g.extra) { later(() => bell(0.55), 350); maybePiece("capstone"); } else maybePiece("garden"); // the close of a world: the temple bell
el("next").hidden = false; renderNav(); update();
startPoem();
}
/* ---------- the poem: whatever the raking (off par, or with the abbot's help), a poem is its reward. It is laid over the
raked garden; about ten seconds later the garden and its poem fade, and the next garden fades in. A tap on the poem
goes on at once; a tap beside it stays with this garden; "original" shows the Japanese and the source (and stays); Next garden
or Enter goes on at once. ---------- */
const POEM_T = { delay: 900, hold: 10000, fade: 1200 }; // ms (a test hook can shorten them)
let poemRun = null, leaveTimer = null;
const reduceMotion = () => window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches;
function poemTitles(stay) { // what a tap does, for the tooltips: on the card, and beside it
const box = el("poem"), card = box.firstElementChild;
box.title = stay ? "Tap to put the poem away" : "Tap beside the poem to stay with this garden";
card.title = stay ? "Tap to put the poem away" : "Tap the poem to go on to the next garden";
}
function fillPoem(p, box) {
poemTitles(!poemRun || poemRun.stay || isDaily());
el("verse").textContent = p.en; el("poet").textContent = p.poet;
el("ja").textContent = p.ja; el("ja").hidden = true;
const src = el("src"), facts = [p.dates, p.src].filter(Boolean).join(" · "); // (the poet's name in kanji was here: clutter, at Gwern's eye)
src.textContent = facts + (facts && p.url ? " · " : "");
if (p.url) { const a = document.createElement("a"); a.href = p.url; a.target = "_blank"; a.rel = "noopener"; a.textContent = "source"; src.appendChild(a); }
src.hidden = true; el("orig").hidden = !p.ja; // (an original poem would have no Japanese to show)
fitPoem(box);
}
function fitPoem(box) { // over the sand only (the walls stay dark), in the largest type that fits there, clear of the seal
const sc = cv.clientWidth / cv.width, wall = Math.round(geom.wall * sc);
box.style.inset = `${wall}px`;
const card = box.firstElementChild, room = box.clientHeight * 0.92, seal = el("seal");
card.style.maxWidth = ""; card.style.justifySelf = ""; seal.classList.remove("under");
const top = Math.max(13, Math.min(21, box.clientWidth / 19));
const tryAll = () => {
for (let fs = top; fs > 10.5; fs -= 0.5) { box.style.setProperty("--poem-fs", fs + "px"); if (card.scrollHeight <= room && !sealClash()) return true; }
return false;
};
if (tryAll() || seal.hidden) return;
const b = box.getBoundingClientRect(), pad = parseFloat(getComputedStyle(box).paddingLeft); // centred, it reaches the seal:
card.style.justifySelf = "start"; // set it flush left, short of the seal
card.style.maxWidth = `${Math.max(120, sealBox().left - b.left - pad - 10)}px`;
if (tryAll()) return;
card.style.maxWidth = ""; card.style.justifySelf = ""; tryAll(); // and if even that is too tall, the seal steps back under the text
seal.classList.add("under");
}
function sealBox() { // where the seal rests (its layout box: not mid-stamp, when it is scaled up)
const s = el("seal"), st = el("stage").getBoundingClientRect(), l = st.left + s.offsetLeft, t = st.top + s.offsetTop;
return { left: l, top: t, right: l + s.offsetWidth, bottom: t + s.offsetHeight };
}
function sealClash() { // does any line of the poem run under the seal (drawn above it)?
const s = el("seal"); if (s.hidden) return false;
const r = sealBox(), m = 6; // (the margin covers its tilt)
for (const id of ["verse", "poet", "orig", "ja", "src"]) {
const e = el(id); if (e.hidden) continue;
const rg = document.createRange(); rg.selectNodeContents(e);
for (const q of rg.getClientRects()) if (q.width && q.right > r.left - m && q.left < r.right + m && q.bottom > r.top - m && q.top < r.bottom + m) return true;
}
return false;
}
function startPoem() {
cancelPoem();
const g = GARDENS[idx];
if (!g.poem) return;
poemRun = { idx, stay: false, shown: false, timer: setTimeout(showPoem, POEM_T.delay) };
}
function showPoem() {
if (!poemRun || idx !== poemRun.idx || mode !== "done" || view !== "puzzle") return;
const box = el("poem");
box.hidden = false; fillPoem(GARDENS[idx].poem, box); // (shown before it is measured, still transparent)
void box.offsetWidth; box.classList.add("on");
poemRun.shown = true;
if (isDaily()) { poemRun.stay = true; el("tick").hidden = true; appendStatus(" Tap the poem to put it away."); return; } // the daily garden has no next garden
const tick = el("tick"); tick.hidden = false; tick.style.setProperty("--hold", POEM_T.hold + "ms");
tick.classList.remove("run"); void tick.offsetWidth; tick.classList.add("run");
appendStatus(" The next garden follows: tap the poem to go on now, or beside it to stay.");
poemRun.timer = setTimeout(tryAdvance, POEM_T.hold);
}
function hidePoem() { const box = el("poem"); box.classList.remove("on"); box.hidden = true; el("seal").classList.remove("under"); }
function cancelPoem() { // any change of garden or mode ends the poem and any pending change of garden
if (poemRun) clearTimeout(poemRun.timer);
poemRun = null; hidePoem();
if (leaveTimer) { clearTimeout(leaveTimer); leaveTimer = null; }
for (const e of [el("stage"), el("where")]) e.classList.remove("leaving");
}
function stayPoem(keep = false) { // stay with this garden: no next garden; the poem can be read again
if (!poemRun) return;
clearTimeout(poemRun.timer); poemRun.stay = true; el("tick").hidden = true; poemTitles(true);
if (!keep) hidePoem();
setStatus(`${statusText().replace(" The next garden follows: tap the poem to go on now, or beside it to stay.", "")} Staying: tap a cell to walk back to it, or go on with Next garden.`);
el("actions").innerHTML = "";
if (mode === "done") shareAction(GARDENS[idx], el("seal").hidden ? null : el("seal").classList.contains("grey") ? "grey" : "red");
addAction("Read the poem again", () => { const box = el("poem"); box.hidden = false; fillPoem(GARDENS[idx].poem, box); void box.offsetWidth; box.classList.add("on"); el("tick").hidden = true; }, "Lay the poem over the garden again");
}
function inView() { const r = cv.getBoundingClientRect(); return r.bottom > 0 && r.top < window.innerHeight; }
function tryAdvance() {
if (!poemRun || poemRun.stay || mode !== "done" || idx !== poemRun.idx || view !== "puzzle") return;
if (document.hidden || !inView()) { poemRun.timer = setTimeout(tryAdvance, 1000); return; } // never while nobody is looking
advance(false);
}
function advance(fast) { // the garden and its poem fade out, and the next garden fades in
if (shared) { leaveShared(); return; }
const nx = nextGarden(idx);
const go = () => { leaveTimer = null; load(nx); maybeDemo(); if (!reduceMotion()) arrive(); };
if (poemRun) clearTimeout(poemRun.timer);
if (reduceMotion()) { go(); return; }
const f = fast ? 450 : POEM_T.fade;
for (const e of [el("stage"), el("where")]) { e.style.setProperty("--fade", f + "ms"); e.classList.remove("arriving"); e.classList.add("leaving"); }
leaveTimer = setTimeout(go, f);
}
function arrive() {
for (const e of [el("stage"), el("where")]) {
e.style.setProperty("--fade", POEM_T.fade + "ms"); e.classList.remove("leaving", "arriving"); void e.offsetWidth; e.classList.add("arriving");
setTimeout(() => e.classList.remove("arriving"), POEM_T.fade + 50);
}
}
/* A tap on the poem itself goes on to the next garden at once (what waiting does); a tap beside it, on the sand around the
card or on the walls, puts the poem away and stays with this garden. (A player's note: interacting with the poem should
go forth, dismissing it should go back.) Once the poem is staying (the daily garden, or Read the poem again), any tap puts it away. */
el("poem").addEventListener("click", ev => {
if (ev.target.closest("#orig, a")) return;
if (poemRun && !poemRun.stay) { if (ev.target.closest(".card")) advance(true); else stayPoem(false); } else hidePoem();
});
el("orig").onclick = ev => {
ev.stopPropagation();
el("ja").hidden = false; el("src").hidden = false; el("orig").hidden = true;
if (poemRun && !poemRun.stay) stayPoem(true); else el("tick").hidden = true;
fitPoem(el("poem"));
};
/* ---------- sharing: a raking, or a player's whole progress, as a code in the URL's hash ----------
A walk is written as a number in a mixed radix: at each step the digit is which of the legal moves (by the garden's
rules, in the order N E S W) was taken and the radix how many there were; so a raking of a 7×7 garden is some 20–50
bits, and no code decodes to a walk that breaks a rule. A garden is named in a code by a 32-bit hash of its layout and
rules, never by its place in the campaign: a link outlives a reordering, and a garden changed since is recognized as
another, not mistaken for the one meant (Astra's point: an 8-bit check would collide once in 256, and a seal must never
land on a different puzzle). The codes are base64url. #r= is a raking: version 4 bits; daily 1; day number 16 (offset
32768) if daily; layout 32; raked with the abbot's help 1; the walk's bits 8, then the walk. #p= is progress: version 4;
raked gardens 8, each layout 32, kind 2 (1 raked, 2 sealed in grey, 3 sealed), best turns over par 6; daily records 9,
each day number 16, kind 2, turns over par 5, with a walk 1 (the day's raking: layout 32, bits 8, walk); an unfinished
walk 1 (layout 32, steps 8, with help 1, bits 8, walk). */
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
const CODE_V = 1;
function bitWriter() {
const bits = [];
return { put(v, n) { v = BigInt(v); for (let i = n - 1; i >= 0; i--) bits.push(Number((v >> BigInt(i)) & 1n)); return this; },
str() { let s = ""; for (let i = 0; i < bits.length; i += 6) { let v = 0; for (let j = 0; j < 6; j++) v = v * 2 + (bits[i + j] || 0); s += B64[v]; } return s; } };
}
function bitReader(s) {
const bits = []; let p = 0;
for (const ch of s) { const v = B64.indexOf(ch); if (v < 0) throw new Error("it is not a Samon code"); for (let j = 5; j >= 0; j--) bits.push((v >> j) & 1); }
return { big(n) { if (p + n > bits.length) throw new Error("it is cut short"); let v = 0n; for (let i = 0; i < n; i++) v = (v << 1n) | BigInt(bits[p++]); return v; },
num(n) { return Number(this.big(n)); } };
}
const bitLen = n => n ? n.toString(2).length : 0;
function legalMoves(S) { const v = S.P[S.len - 1], out = []; for (let d = 0; d < 4; d++) { const w = G.nbr[v * 4 + d]; if (w >= 0 && check(S, w) === null) out.push(w); } return out; }
function walkEncode(P) { // the walk P (from the gate of the garden G) as a number; null if it breaks a rule
if (!P.length || P[0] !== G.gateCell) return null;
const S = newState(); push(S, P[0]);
let n = 0n, m = 1n;
for (let i = 1; i < P.length; i++) {
const opts = legalMoves(S), j = opts.indexOf(P[i]);
if (j < 0) return null;
n += BigInt(j) * m; m *= BigInt(opts.length); push(S, P[i]);
}
return n;
}
function walkDecode(n, steps) { // the walk of `steps` steps whose number is n; null if there is none
const S = newState(); push(S, G.gateCell); const P = [G.gateCell];
for (let i = 0; i < steps; i++) {
const opts = legalMoves(S); if (!opts.length) return null;
const r = BigInt(opts.length), j = Number(n % r); n /= r;
push(S, opts[j]); P.push(opts[j]);
}
return n === 0n ? P : null;
}
function withGarden(g, fn) { const keep = G; G = buildGarden(g); try { return fn(); } finally { G = keep; } } // run fn with g built as the garden
function layoutHash(g) { // a garden's identity in a code: 32 bits (FNV-1a) of its layout and rules, in a fixed order
const kv = o => Object.entries(o || {}).sort().map(([k, v]) => `${k}:${v}`).join(";");
const s = [g.rows.join("/"), g.gate.join(","), (g.end || []).join(","), g.end_side || "", g.wide ? "w" : "", kv(g.marks), kv(g.clues)].join("|");
let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
return h >>> 0;
}
let LAYOUTS = null; // layout hash → campaign index (built once; a test hook that reorders the campaign resets it)
function byLayout(h) {
if (!LAYOUTS) { LAYOUTS = new Map(); for (let i = 0; i < NCAMP; i++) LAYOUTS.set(layoutHash(GARDENS[i]), i); }
const i = LAYOUTS.get(h); return i === undefined ? -1 : i;
}
function putWalk(w, P) { const n = walkEncode(P); if (n === null) return false; w.put(bitLen(n), 8).put(n, bitLen(n)); return true; }
function readWalk(r, steps) { const P = walkDecode(r.big(r.num(8)), steps); if (!P) throw new Error("its walk does not fit the garden"); return P; }
function skipWalk(r) { r.big(r.num(8)); } // (a walk of a garden not in this edition: read past it)
function dailyFor(n) { // the daily garden of day number n (days since 2026-01-01), under its date
const i = ((n % DAILY.length) + DAILY.length) % DAILY.length, d = new Date(Date.UTC(2026, 0, 1) + n * 86400000);
const k = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
return Object.assign({}, DAILY[i], { name: "daily:" + k, daily: true, dateKey: k, dayNo: n, poem: GARDENS[((n % NCAMP) + NCAMP) % NCAMP].poem });
}
function rakingCode(P = path, grey = false) { // the current garden's complete raking P as a #r= code; null if P is not one
const g = GARDENS[idx];
if (P.length !== G.nSand || finishCheck(stateOf(P))) return null;
const w = bitWriter().put(CODE_V, 4).put(g.daily ? 1 : 0, 1);
if (g.daily) w.put(g.dayNo + 32768, 16);
w.put(layoutHash(g), 32).put(grey ? 1 : 0, 1);
return putWalk(w, P) ? w.str() : null;
}
function readRaking(code) { // a #r= code: the garden's index (a daily one is put in the daily slot), whether helped, and the walk's number
const r = bitReader(code);
if (r.num(4) !== CODE_V) throw new Error("it was made by another edition of the game");
let i;
if (r.num(1)) {
if (!DAILY.length) throw new Error("this edition has no daily gardens");
const g = dailyFor(r.num(16) - 32768);
if (r.num(32) !== layoutHash(g)) throw new Error("the garden of that day has changed since");
GARDENS[NCAMP] = g; i = NCAMP;
} else { i = byLayout(r.num(32)); if (i < 0) throw new Error("its garden is not in this edition, or has changed since"); }
const grey = r.num(1) === 1, n = r.big(r.num(8));
return { i, grey, n };
}
function showShared(h) {
load(h.i);
const g = GARDENS[idx], P = walkDecode(h.n, G.nSand - 1);
if (!P || finishCheck(stateOf(P))) throw new Error("its walk does not fit the garden");
cancelPoem(); clearAdvice();
path = P; mode = "done"; doneAt = 0; rakedHelped = false; cut = null;
const st = strokeOf(path); rakeAlong(PZ, st, 0, st.L); PZ.dirty = null; PZ.feet = null; fieldLight(PZ);
const t = turnsOf(), kind = t <= g.par ? (h.grey ? "grey" : "red") : null;
shared = { turns: t, par: g.par, kind };
showSeal(kind);
el("next").hidden = true;
const today = g.daily && g.dateKey === dateKey(todayDate()), mine = g.daily && (progress[g.name] || {}).done;
setStatus(`A raking shared with you${g.daily ? `, of the daily garden of ${dateLabel(g.dateKey)}` : ""}: ${t} turn${t === 1 ? "" : "s"}, par ${g.par}${kind === "red" ? ", the abbot's seal" : kind === "grey" ? ", his seal in grey (raked with his help)" : ""}.`
+ (g.daily && !today ? " Today's garden is another." : mine ? ` Your own raking today: ${progress[g.name].turns} turns.` : " Rake it yourself from the gate: a tap smooths the sand."));
addAction(g.daily && !today ? "Today's garden" : mine ? "Your own raking" : "Rake it yourself", leaveShared,
g.daily && !today ? "This was an earlier day's garden; today's is another, and yours to rake" : mine ? "Smooth this away and see your own raking of today's garden" : "Smooth this raking away and rake the garden yourself, from the gate");
addAction("Share it on", () => share(g, path, kind), "Pass this raking on as it came: drawn in lines, with a link that shows it");
update();
}
function leaveShared() { // the shared raking is put away, and the garden is the viewer's own (today's, after a past daily garden)
if (!shared) return;
const g = GARDENS[idx]; shared = null; dropHash();
if (g.daily && g.dateKey !== dateKey(todayDate())) { pickDaily(); return; }
load(idx);
if (!path.length && mode === "draw") setStatus("Now you: drag the monk in from the gate.");
maybeDemo();
}
function dropHash() { if (/^#([rp]=|daily$)/.test(location.hash)) history.replaceState(null, "", location.pathname + location.search); }
function progressCode() { // all of this player's progress as a #p= code
const w = bitWriter().put(CODE_V, 4), raked = [];
for (let i = 0; i < NCAMP; i++) { const g = GARDENS[i], p = progress[g.name] || {}, kind = p.sealed ? 3 : p.grey ? 2 : p.done ? 1 : 0; if (kind) raked.push([g, p, kind]); }
w.put(Math.min(255, raked.length), 8);
for (const [g, p, kind] of raked.slice(0, 255)) w.put(layoutHash(g), 32).put(kind, 2).put(Math.max(0, Math.min(63, (p.best ?? g.par) - g.par)), 6);
const days = DAILY.length ? Object.keys(progress).filter(k => /^daily:\d{4}-\d\d-\d\d$/.test(k) && progress[k].done).sort().slice(-511) : [];
w.put(days.length, 9);
for (const k of days) {
const [y, m, d] = k.slice(6).split("-").map(Number), p = progress[k], n = dayNumber(new Date(y, m - 1, d)), g = dailyFor(n);
w.put(n + 32768, 16).put(p.kind === "red" ? 3 : p.kind === "grey" ? 2 : 1, 2).put(Math.max(0, Math.min(31, (p.turns ?? g.par) - g.par)), 5);
const walk = Array.isArray(p.path) && withGarden(g, () => p.path.length === G.nSand && walkEncode(p.path) !== null);
w.put(walk ? 1 : 0, 1); if (walk) { w.put(layoutHash(g), 32); withGarden(g, () => putWalk(w, p.path)); }
}
let oi = GARDENS[idx] && !GARDENS[idx].daily ? idx : -1; // the garden open (or last open) and its unfinished walk
if (oi < 0) try { const i = parseInt(localStorage.getItem("samon-last")); if (GARDENS[i] && !GARDENS[i].daily) oi = i; } catch (e) {}
const p = oi >= 0 ? progress[GARDENS[oi].name] || {} : {}, P = oi >= 0 ? (oi === idx && mode === "draw" ? path : p.draft) : null;
const ok = Array.isArray(P) && P.length > 1 && withGarden(GARDENS[oi], () => walkEncode(P) !== null);
w.put(ok ? 1 : 0, 1);
if (ok) { w.put(layoutHash(GARDENS[oi]), 32).put(P.length - 1, 8).put(oi === idx ? (helped ? 1 : 0) : (p.helped ? 1 : 0), 1); withGarden(GARDENS[oi], () => putWalk(w, P)); }
return w.str();
}
function readProgress(code) { // a #p= code → { gardens: [{ i, kind, over }], days: [{ g, kind, over, P }], draft: { i, P, helped } | null, lost }
const r = bitReader(code); // (lost: entries of gardens not in this edition, or changed since, which are left out rather than guessed at)
if (r.num(4) !== CODE_V) throw new Error("it was made by another edition of the game");
const nr = r.num(8), gardens = []; let lost = 0;
for (let k = 0; k < nr; k++) { const i = byLayout(r.num(32)), kind = r.num(2), over = r.num(6); if (i >= 0 && kind) gardens.push({ i, kind, over }); else lost++; }
const nd = r.num(9), days = [];
for (let k = 0; k < nd; k++) {
const n = r.num(16) - 32768, kind = r.num(2), over = r.num(5), g = DAILY.length ? dailyFor(n) : null;
let P = null;
if (r.num(1)) { const h = r.num(32); if (g && h === layoutHash(g)) P = withGarden(g, () => readWalk(r, G.nSand - 1)); else skipWalk(r); }
if (g) days.push({ g, kind, over, P }); else lost++;
}
let draft = null;
if (r.num(1)) {
const i = byLayout(r.num(32)), steps = r.num(8), helped = r.num(1) === 1;
if (i >= 0) draft = { i, helped, P: withGarden(GARDENS[i], () => readWalk(r, steps)) }; else { skipWalk(r); lost++; }
}
return { gardens, days, draft, lost };
}
function importProgress(h) { // the progress of a #p= code joins this player's: whichever is better stands
for (const e of h.gardens) {
const g = GARDENS[e.i]; if (!g || g.daily || !e.kind) continue;
const p = progress[g.name] || {};
p.done = true; p.best = Math.min(p.best ?? 1e9, g.par + e.over);
if (e.kind === 3) p.sealed = true; else if (e.kind === 2) p.grey = true;
progress[g.name] = p;
}
for (const d of h.days) {
const p = progress[d.g.name] || {};
if (p.done) continue; // the first raking of a day is its record
Object.assign(p, { done: true, turns: d.g.par + d.over, par: d.g.par, kind: d.kind === 3 ? "red" : d.kind === 2 ? "grey" : null, day: d.g.dateKey });
if (d.P) p.path = d.P;
progress[d.g.name] = p;
}
if (h.draft) {
const g = GARDENS[h.draft.i], p = progress[g.name] || {};
if (!p.done && !(Array.isArray(p.draft) && p.draft.length > 1)) {
p.draft = h.draft.P; if (h.draft.helped) p.helped = true; progress[g.name] = p;
try { localStorage.setItem("samon-last", String(h.draft.i)); } catch (e) {}
}
}
saveProgress();
}
function offerProgress(h) {
const raked = h.gardens.filter(e => e.kind).length, sealed = h.gardens.filter(e => e.kind === 3).length, days = h.days.length;
const sum = `${raked} of ${NCAMP} gardens raked, ${sealed} sealed${days ? `, ${days} daily garden${days === 1 ? "" : "s"}` : ""}${h.draft ? ", and an unfinished walk" : ""}`;
setStatus(`This link carries a player's progress: ${sum}. Take it over here? It joins what is here, and whichever is better stands.${h.lost ? ` (${h.lost} of its entries belong to gardens not in this edition, or changed since, and are left out.)` : ""}`);
el("actions").innerHTML = "";
addAction("Take it over", () => { importProgress(h); dropHash(); load(lastGarden()); setStatus(`Taken over: ${sum}.`); maybeDemo(); }, "Join the link's progress with what is here: every garden keeps its better state, and nothing here is lost");
addAction("Leave it", () => { dropHash(); clearAdvice(); setStatus("Left where it was. This is your own garden."); }, "Keep only what is here; the link is dropped");
}
function applyHash() { // a #r= or #p= code in the URL: show the raking, or offer the progress; true if the hash was one
if (location.hash === "#daily") { if (DAILY.length) { pickDaily(); dropHash(); } return DAILY.length > 0; }
const m = /^#([rp])=(.*)$/.exec(location.hash);
if (!m) return false;
try {
if (m[1] === "r") showShared(readRaking(m[2]));
else { const h = readProgress(m[2]); load(lastGarden()); offerProgress(h); }
} catch (e) { if (!G) load(lastGarden()); setStatus(`The link's code can't be read: ${e.message}.`, true); dropHash(); }
showStage();
return true;
}
window.addEventListener("hashchange", () => { if (/^#([rp]=|daily$)/.test(location.hash)) applyHash(); });
/* The share: the raking drawn in box-drawing characters (light lines for the stroke, heavy for the walls, blocks for the
stones), with a link that opens it; through the share sheet where there is one, else to the clipboard. */
function gardenLabel(g) {
if (g.daily) return `daily garden ${g.dateKey}`;
const inPart = GARDENS.filter(x => x.world === g.world && !!x.extra === !!g.extra);
return `${TIMES[g.world]}${g.extra ? ", more" : ""} ${inPart.indexOf(g) + 1} · ${g.name}`;
}
function gridText(P = path) {
const CH = { 0: "░", 1: "╵", 2: "╶", 4: "╷", 8: "╴", 5: "│", 10: "─", 3: "└", 9: "┘", 6: "┌", 12: "┐" }; // bits: 1 N, 2 E, 4 S, 8 W
const conn = new Array(G.N).fill(0);
for (let i = 0; i < P.length; i++) {
conn[P[i]] |= 1 << (i === 0 ? G.gateDir : dirTo(P[i], P[i - 1]));
if (i + 1 < P.length) conn[P[i]] |= 1 << dirTo(P[i], P[i + 1]);
}
if (G.endCell >= 0 && G.end_side && P.length === G.nSand && P[P.length - 1] === G.endCell) conn[G.endCell] |= 1 << DIX[G.end_side];
const gap = (side, r, c) => (G.gate[2] === side && (side === "N" || side === "S" ? G.gate[1] === c : G.gate[0] === r))
|| (!!G.end && G.end_side === side && (side === "N" || side === "S" ? G.end[1] === c : G.end[0] === r));
const across = side => [...Array(G.W)].map((_, c) => gap(side, -1, c) ? (side === "N" ? "┯" : "┷") : "━").join("");
const lines = ["┏" + across("N") + "┓"];
for (let r = 0; r < G.H; r++) {
let s = gap("W", r, -1) ? "┠" : "┃";
for (let c = 0; c < G.W; c++) { const v = key(r, c); s += G.sand[v] ? (CH[conn[v]] || "┼") : G.rows[r][c] === "R" ? "▒" : "█"; }
lines.push(s + (gap("E", r, -1) ? "┨" : "┃"));
}
lines.push("┗" + across("S") + "┛");
return lines.join("\n");
}
function shareText(g, P, kind) { // the daily garden's share shows its shape and the score, not the stroke: today everyone is still raking it
const t = turnsOf(P), seal = kind === "red" ? " · 静" : kind === "grey" ? " · 静 (with the abbot's help)" : "", streak = g.daily ? dailyStreak() : 0;
return `Samon · ${gardenLabel(g)} · ${t} turn${t === 1 ? "" : "s"}, par ${g.par}${seal}${streak ? ` · streak ${streak}` : ""}\n${gridText(g.daily ? [] : P)}`;
}
const pageURL = () => window.top === window ? location.href.replace(/#.*$/, "") : ""; // (framed, as the artifact is, the page's own address is not one to pass on)
async function shareOut(text, url, title, clip) { // the share sheet where there is one; else the clipboard (clip, or the text with the link); else the text, to copy by hand
if (navigator.share) { try { await navigator.share(url ? { title, text, url } : { title, text }); return "shared"; } catch (e) { if (e && e.name === "AbortError") return "cancelled"; } }
try { await navigator.clipboard.writeText(clip ?? (url ? `${text}\n${url}` : text)); return "copied"; } catch (e) { return "shown"; }
}
function showShareText(t) {
const ta = document.createElement("textarea"); ta.className = "share"; ta.readOnly = true; ta.value = t; ta.rows = Math.min(16, t.split("\n").length + 2);
ta.setAttribute("aria-label", "Text to share"); el("actions").appendChild(ta); ta.focus(); ta.select();
}
async function share(g, P, kind) {
const code = g.daily ? "daily" : rakingCode(P, kind === "grey"), text = shareText(g, P, kind), base = pageURL();
const url = code && base ? `${base}#${g.daily ? "daily" : "r=" + code}` : "";
const how = await shareOut(text, url, "Samon");
const what = g.daily ? "today's garden and your score" : "the raking drawn in lines", opens = g.daily ? "opens today's garden" : "opens it";
if (how === "copied") setStatus(`Copied: ${what}${url ? `, with a link that ${opens}` : ""}. Paste it anywhere.`);
else if (how === "shown") { setStatus(`Copy this to share it${url ? `; the link ${opens}` : ""}.`); showShareText(url ? `${text}\n${url}` : text); }
}
async function carryProgress() {
const base = pageURL(), url = `${base || location.href.replace(/#.*$/, "")}#p=${progressCode()}`;
const how = await shareOut("Samon: my progress, to take over on another device.", url, "Samon progress", url);
const note = base ? "" : " (Shown inside another page, as here, the link may not be the page's public address: paste the code after # onto that.)";
if (how === "copied") setStatus(`Copied a link that carries your progress: open it on the other device and choose Take it over.${note}`);
else if (how === "shown") { setStatus(`Open this link on the other device and choose Take it over.${note}`); showShareText(url); }
}
function shareAction(g, kind) {
addAction(g.daily ? "Share today's score" : "Share this raking", () => share(g, path.slice(), kind),
`${navigator.share ? "Through the share sheet" : "To the clipboard"}: ` + (g.daily ? "your turns against par, the garden's shape without your stroke (everyone is still raking it today), and a link that opens today's garden"
: "the raking drawn in lines, and a link that shows it in the garden (shown, not credited: whoever opens it rakes it for themselves)"));
}
/* ---------- assets carried in the page. A recording (or the single-file edition's .zip) is written into the page as a
data: URL inside an inert <script type="text/plain">. Gwern's gwtar tool (deconstruct_singlefile.php, gwern.net/gwtar)
pulls every data: URL out of such a page into an appended tar and leaves its path in its place, and its loader (gwtar.js)
fetches members by HTTP range request on demand; so an asset's text is either the data: URL itself, a path the gwtar
loader can serve (resolved through its globals, in either of its two modes), or a plain path beside an extracted page. */
const samonAsset = {
gwtar() { return typeof assetInfoFromResourceURLString === "function" && typeof assets === "object"; },
bytes(ref) { // Promise of a Uint8Array
if (ref.startsWith("data:")) { const s = atob(ref.slice(ref.indexOf(",") + 1)), b = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); return Promise.resolve(b); }
const info = this.gwtar() ? assetInfoFromResourceURLString(ref) : null;
if (!info) return fetch(ref).then(r => { if (!r.ok) throw new Error(r.status); return r.arrayBuffer(); }).then(a => new Uint8Array(a));
return new Promise((res, rej) => {
const got = () => { if (info.data) { clearInterval(t); res(new Uint8Array(info.data)); return true; } return false; };
if (got()) return;
const t = setInterval(got, 250);
try { // range mode: ask for the member; full-response mode: mark it wanted and let the stream fill it in
if (typeof loadedResponseDataLength === "undefined" || loadedResponseDataLength === 0) getResources([info], { onFailure: () => { clearInterval(t); rej(new Error("gwtar")); } });
else markAssetWaiting(info);
} catch (e) { clearInterval(t); rej(e); }
});
},
url(ref, type) { // Promise of a URL a media element or a link can use
if (ref.startsWith("data:")) return Promise.resolve(ref);
if (!this.gwtar() || !assetInfoFromResourceURLString(ref)) return Promise.resolve(ref);
return this.bytes(ref).then(b => URL.createObjectURL(new Blob([b], { type: type || assetInfoFromResourceURLString(ref)["content-type"] || "" })));
},
text(el) { return (el.textContent || "").trim().replace(/^["']|["']$/g, ""); } // (the gwtar tool quotes the path it writes)
};
/* ---------- sound: synthesized in the page (a bamboo pipe that tips and drips now and then, a temple bell at the close
of a world, the seal's wooden tok), and shakuhachi from the recordings carried in the page (script.piece, see above).
Nothing sounds until the player has touched the page (browsers require it), and Sound off in the list silences all. ---------- */
const SND = { on: true, vol: 1, haptics: true, ctx: null, out: null, master: null, basin: null, meter: null, wind: null, scr: null, timers: new Set(), tracks: null, cur: null, lastPiece: 0, played: [], offline: null };
try {
SND.on = localStorage.getItem("samon-sound") !== "0"; SND.haptics = localStorage.getItem("samon-haptics") !== "0";
const v = parseFloat(localStorage.getItem("samon-sound-vol")); if (v >= 0 && v <= 1) SND.vol = v;
} catch (e) {}
function later(fn, ms) { // a delay in wall-clock time; in an offline render (the test hook), in rendered time
if (SND.offline) { offlineAt(SND.ctx.currentTime + ms / 1000, fn); return 0; }
const id = setTimeout(() => { SND.timers.delete(id); fn(); }, ms); SND.timers.add(id); return id;
}
function offlineAt(when, fn) { // run fn with the offline context suspended at `when` (one suspend per render quantum), then go on rendering
const ctx = SND.ctx, q = 128 / ctx.sampleRate; let k = Math.max(1, Math.ceil(when / q - 1e-6)); // (never at frame 0, which cannot be suspended)
while (SND.offline.has(k)) k++; SND.offline.add(k);
ctx.suspend(k * q).then(() => { fn(); ctx.resume(); }).catch(() => {});
}
function audio() { // the context, made on a gesture; null when sound is off or unavailable
if (!SND.on) return null;
if (!SND.ctx) {
const C = window.AudioContext || window.webkitAudioContext; if (!C) return null;
const ctx = SND.ctx = new C();
SND.master = ctx.createGain(); SND.master.gain.value = SND.vol; SND.master.connect(ctx.destination); // the player's volume
SND.out = ctx.createGain(); SND.out.gain.value = 0.8; SND.out.connect(SND.master);
SND.meter = ctx.createAnalyser(); SND.meter.fftSize = 2048; SND.out.connect(SND.meter); // (tests read the output level from it)
const d = ctx.createDelay(0.3), fb = ctx.createGain(), lp = ctx.createBiquadFilter(), wet = ctx.createGain(); // the stone basin's echo
d.delayTime.value = 0.057; fb.gain.value = 0.4; lp.type = "lowpass"; lp.frequency.value = 1600; wet.gain.value = 0.45;
d.connect(lp); lp.connect(fb); fb.connect(d); lp.connect(wet); wet.connect(SND.out);
SND.basin = d;
ctx.onstatechange = () => { if (SND.ctx === ctx) renderMute(); };
windStart(); ambientStart(); loadPieces();
let seen = true; try { seen = !!localStorage.getItem("samon-sound-hint"); localStorage.setItem("samon-sound-hint", "1"); } catch (e) {}
if (!seen) { // the first time sound starts, a word beside the speaker, briefly
const h = document.createElement("span"); h.className = "mute-hint"; h.id = "muteHint"; h.textContent = "[Sound on · M mutes]"; el("mute").insertAdjacentElement("beforebegin", h); // (beside the speaker, in the header's right-hand group)
setTimeout(() => h.remove(), 7000);
}
}
if (!SND.offline) {
SND.gesture = performance.now(); later(renderMute, 1600);
if (SND.ctx.state === "suspended") SND.ctx.resume().catch(() => {}); // (an offline render resumes itself once the sound is scheduled)
}
return SND.ctx;
}
function noiseBuf(ctx, secs) { const b = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * secs), ctx.sampleRate), x = b.getChannelData(0); for (let i = 0; i < x.length; i++) x[i] = Math.random() * 2 - 1; return b; }
function partial(ctx, dest, f, amp, dec, t, type = "sine") { // one decaying sine
const o = ctx.createOscillator(), g = ctx.createGain(); o.type = type; o.frequency.value = f;
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(amp, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0004, t + dec);
o.connect(g); g.connect(dest); o.start(t); o.stop(t + dec + 0.05);
}
function click(ctx, dest, amp, dec, t, cutoff) { // a short burst of noise
const s = ctx.createBufferSource(); s.buffer = noiseBuf(ctx, dec + 0.01); const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = cutoff;
const g = ctx.createGain(); g.gain.setValueAtTime(amp, t); g.gain.exponentialRampToValueAtTime(0.0005, t + dec);
s.connect(lp); lp.connect(g); g.connect(dest); s.start(t); s.stop(t + dec + 0.02);
}
function drip(vol = 0.2) { // one drop into the basin: a short rising sine, and the basin's echo
const ctx = audio(); if (!ctx) return; const t = ctx.currentTime + 0.01;
const o = ctx.createOscillator(), g = ctx.createGain(); o.type = "sine";
const f0 = 450 + Math.random() * 450, dur = 0.08 + Math.random() * 0.09;
o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f0 * (2.1 + Math.random() * 0.9), t + 0.035);
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(vol, t + 0.003); g.gain.exponentialRampToValueAtTime(0.0004, t + dur);
o.connect(g); g.connect(SND.out); g.connect(SND.basin); o.start(t); o.stop(t + dur + 0.05);
}
function tock(vol = 0.4) { // the bamboo pipe tipping onto its stone, then the water it spills
const ctx = audio(); if (!ctx) return; const t = ctx.currentTime + 0.01, k = 0.94 + Math.random() * 0.12;
click(ctx, SND.out, vol * 0.8, 0.012, t, 2600);
for (const [f, a, dec] of [[165, 0.9, 0.11], [610, 0.55, 0.16], [1230, 0.35, 0.12], [2210, 0.16, 0.07]]) partial(ctx, SND.out, f * k, vol * a, dec, t);
const n = 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < n; i++) later(() => drip(0.1 + Math.random() * 0.1), 220 + i * (150 + Math.random() * 200));
}
function tok(vol = 0.35) { // the seal: a dull wooden knock
const ctx = audio(); if (!ctx) return; const t = ctx.currentTime + 0.01;
click(ctx, SND.out, vol, 0.01, t, 1800); partial(ctx, SND.out, 330, vol * 0.8, 0.07, t); partial(ctx, SND.out, 1050, vol * 0.3, 0.035, t);
}
function bell(vol = 0.5, f0 = 108) { // a bonshō: inharmonic partials, the low ones beating slowly and lasting long, under the log's strike
const ctx = audio(); if (!ctx) return; const t = ctx.currentTime + 0.02;
const mix = ctx.createGain(); mix.gain.value = vol * 0.32; mix.connect(SND.out);
for (const [r, a, dec] of [[1, 1, 17], [1.004, 0.6, 15], [2.0, 0.55, 9], [2.012, 0.3, 8], [2.4, 0.35, 6], [3.01, 0.3, 5], [3.5, 0.2, 3.5], [4.2, 0.15, 2.5], [5.4, 0.1, 1.6], [6.8, 0.07, 1.0], [8.3, 0.05, 0.6]])
partial(ctx, mix, f0 * r, a, dec, t);
click(ctx, SND.out, vol * 0.5, 0.045, t, 900);
}
/* the rake's scrape: gravel under the tines, as grains. A looping buffer of crackle (random short bursts over a hiss) runs
through a bandpass; the stroke's speed sets its loudness, its brightness and its density (the buffer's playback rate),
and a bend adds the rasp of tines dragged sideways. Nothing is recorded. */
function gravelBuf(ctx, secs) {
const n = Math.ceil(ctx.sampleRate * secs), b = ctx.createBuffer(1, n, ctx.sampleRate), x = b.getChannelData(0);
for (let i = 0; i < n; i++) x[i] = (Math.random() * 2 - 1) * 0.12; // the hiss of fine sand
const grains = Math.floor(secs * 900); // and the grains
for (let k = 0; k < grains; k++) {
const at = Math.floor(Math.random() * n), len = 8 + Math.floor(Math.random() * 60), a = 0.25 + Math.random() * 0.75, ph = Math.random() * 6.283;
for (let i = 0; i < len && at + i < n; i++) x[at + i] += a * Math.exp(-i / (len * 0.35)) * Math.sin(ph + i * (0.9 + Math.random() * 0.6)) * (Math.random() * 2 - 1);
}
let m = 0; for (let i = 0; i < n; i++) m = Math.max(m, Math.abs(x[i])); for (let i = 0; i < n; i++) x[i] /= m;
return b;
}
function scrapeStart() {
const ctx = audio(); if (!ctx || SND.scr) return;
const src = ctx.createBufferSource(); src.buffer = SND.gravel || (SND.gravel = gravelBuf(ctx, 2.5)); src.loop = true;
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 1400; bp.Q.value = 0.7;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 300;
const g = ctx.createGain(); g.gain.value = 0;
const rasp = ctx.createBiquadFilter(); rasp.type = "bandpass"; rasp.frequency.value = 3400; rasp.Q.value = 2.5; // the sideways rasp of a bend
const rg = ctx.createGain(); rg.gain.value = 0;
src.connect(hp); hp.connect(bp); bp.connect(g); g.connect(SND.out); bp.connect(rasp); rasp.connect(rg); rg.connect(SND.out);
src.start();
SND.scr = { src, bp, g, rg, step: 0 };
}
const SCRAPE_LEVEL = 0.85; // the rake's scrape against the rest (dialled down 15% at Gwern's ear, 2026-10-05)
function scrapeSet(speed, bend, vol = 0.5) { // speed in cells per second; bend 1 on an arc
const S = SND.scr, ctx = SND.ctx; if (!S) return;
const v = Math.min(1, speed / 5), t = ctx.currentTime, lv = vol * SCRAPE_LEVEL;
S.g.gain.setTargetAtTime(3 * lv * (0.15 + 0.85 * v) * (speed > 0.02 ? 1 : 0), t, 0.04);
S.rg.gain.setTargetAtTime(lv * 0.9 * v * (bend ? 1 : 0.1), t, 0.06);
S.bp.frequency.setTargetAtTime(900 + 1300 * v, t, 0.08);
S.src.playbackRate.setTargetAtTime(0.6 + 0.7 * v, t, 0.08);
}
function scrapeStop() {
const S = SND.scr, ctx = SND.ctx; if (!S) return; SND.scr = null;
const t = ctx.currentTime; S.g.gain.setTargetAtTime(0, t, 0.05); S.rg.gain.setTargetAtTime(0, t, 0.05);
try { S.src.stop(t + 0.4); } catch (e) {}
}
function crunch(vol = 0.12) { // a footstep on gravel: a burst of grains over a soft thump
const ctx = audio(); if (!ctx) return; const t = ctx.currentTime + 0.005;
const s = ctx.createBufferSource(); s.buffer = SND.gravel || (SND.gravel = gravelBuf(ctx, 2.5)); s.playbackRate.value = 0.9 + Math.random() * 0.5;
const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 900 + Math.random() * 1400; bp.Q.value = 0.8;
const g = ctx.createGain(), dur = 0.05 + Math.random() * 0.04;
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(vol, t + 0.006); g.gain.exponentialRampToValueAtTime(0.0005, t + dur);
s.connect(bp); bp.connect(g); g.connect(SND.out); s.start(t, Math.random() * 2); s.stop(t + dur + 0.02);
partial(ctx, SND.out, 75 + Math.random() * 40, vol * 0.5, 0.045, t);
}
/* the garden's own air: a wind bed always (filtered noise, slowly wandering, with a gust now and then), small birds by day,
crickets after dark; by the local clock, read each time something is scheduled, so an open page follows the evening. */
function dayPart(h = new Date().getHours()) { return h >= 6 && h < 19 ? "day" : (h >= 20 || h < 5) ? "night" : "dusk"; }
function windStart(vol = 0.1) {
const ctx = SND.ctx; if (!ctx || SND.wind) return;
const src = ctx.createBufferSource(); src.buffer = noiseBuf(ctx, 3); src.loop = true;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 500; lp.Q.value = 0.9;
const g = ctx.createGain(); g.gain.value = 0;
src.connect(lp); lp.connect(g); g.connect(SND.out); src.start();
SND.wind = { src, lp, g, vol };
g.gain.setTargetAtTime(vol * 0.5, ctx.currentTime, 2);
}
function windWander() { // the bed drifts: a new level and colour every few seconds
const W = SND.wind; if (!W || !SND.on) return; const t = SND.ctx.currentTime;
W.g.gain.setTargetAtTime(W.vol * (0.3 + Math.random() * 0.7), t, 1.5); W.lp.frequency.setTargetAtTime(350 + Math.random() * 450, t, 1.5);
}
function gust(vol = 0.16, dur = 4) { // a gust: the bed swells and brightens, then settles
const ctx = audio(); if (!ctx) return; windStart(); const W = SND.wind, t = ctx.currentTime;
W.g.gain.cancelScheduledValues(t); W.g.gain.setTargetAtTime(vol, t, dur * 0.3); W.g.gain.setTargetAtTime(W.vol * 0.5, t + dur * 0.45, dur * 0.3);
W.lp.frequency.cancelScheduledValues(t); W.lp.frequency.setTargetAtTime(1100, t, dur * 0.3); W.lp.frequency.setTargetAtTime(500, t + dur * 0.45, dur * 0.3);
}
function birds(vol = 0.06) { // one small bird, a phrase of two to six notes: glides on a sine, a little vibrato
const ctx = audio(); if (!ctx) return; let t = ctx.currentTime + 0.05;
const high = Math.random() < 0.5, base = high ? 3200 + Math.random() * 900 : 1900 + Math.random() * 600, n = 2 + Math.floor(Math.random() * 5);
const g = ctx.createGain(); g.gain.value = vol * (0.7 + Math.random() * 0.3); g.connect(SND.out);
for (let i = 0; i < n; i++) {
const o = ctx.createOscillator(), e = ctx.createGain(), f0 = base * (0.94 + Math.random() * 0.12), up = Math.random() < 0.6, d = 0.06 + Math.random() * 0.09;
o.type = "sine"; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f0 * (up ? 1.25 + Math.random() * 0.2 : 0.78), t + d * 0.7);
const vib = ctx.createOscillator(), vg = ctx.createGain(); vib.frequency.value = 28 + Math.random() * 20; vg.gain.value = f0 * 0.015; vib.connect(vg); vg.connect(o.frequency);
e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(1, t + 0.008); e.gain.exponentialRampToValueAtTime(0.001, t + d);
o.connect(e); e.connect(g); o.start(t); vib.start(t); o.stop(t + d + 0.02); vib.stop(t + d + 0.02);
t += d + 0.05 + Math.random() * 0.16;
}
}
function crickets(vol = 0.035, secs = 0.8) { // one cricket's trill: a high sine pulsed at about 28 Hz, in soft pulses
const ctx = audio(); if (!ctx) return; const t0 = ctx.currentTime + 0.02, f = 3900 + Math.random() * 800, rate = 24 + Math.random() * 10, p = 1 / rate;
const o = ctx.createOscillator(), g = ctx.createGain(); o.type = "sine"; o.frequency.value = f; g.gain.value = 0;
const o2 = ctx.createOscillator(), g2 = ctx.createGain(); o2.type = "sine"; o2.frequency.value = f * 2.01; g2.gain.value = 0.18; o2.connect(g2); g2.connect(g);
const n = Math.floor(secs / p), a = vol * (0.7 + Math.random() * 0.3);
for (let i = 0; i < n; i++) { const t = t0 + i * p, env = Math.sin(Math.PI * (i + 0.5) / n); g.gain.setTargetAtTime(a * env, t, p * 0.12); g.gain.setTargetAtTime(0, t + p * 0.45, p * 0.1); }
o.connect(g); g.connect(SND.out); o.start(t0); o2.start(t0); o.stop(t0 + secs + 0.1); o2.stop(t0 + secs + 0.1);
}
function ambientStart() {
const loop = (fn, lo, hi) => { const go = () => later(() => { if (SND.on && !document.hidden) fn(); go(); }, lo + Math.random() * (hi - lo)); return go; };
later(loop(() => drip(0.1 + Math.random() * 0.12), 7000, 23000), 2500 + Math.random() * 4000);
later(loop(() => tock(0.4), 50000, 130000), 15000 + Math.random() * 25000);
later(loop(windWander, 2500, 6000), 500);
later(loop(() => gust(0.12 + Math.random() * 0.08, 3 + Math.random() * 4), 25000, 70000), 8000 + Math.random() * 20000);
later(loop(() => { if (dayPart() === "day") birds(); }, 12000, 45000), 4000 + Math.random() * 8000);
later(loop(() => { if (dayPart() === "night") crickets(0.035, 0.5 + Math.random() * 0.6); }, 900, 2200), 3000 + Math.random() * 3000);
}
function setSound(on) {
SND.on = on; try { localStorage.setItem("samon-sound", on ? "1" : "0"); } catch (e) {}
if (!on) { for (const id of SND.timers) clearTimeout(id); SND.timers.clear(); stopPiece(); scrapeStop(); if (SND.ctx) SND.ctx.suspend().catch(() => {}); }
else { const again = !!SND.ctx && !SND.timers.size; audio(); if (again) ambientStart(); } // (audio() makes or resumes the context; a new one starts its own ambience)
renderMute();
}
/* Silence, as the player and the browser want it. The mute button (and M) is the page's own switch. Beyond it: the page never
sounds before a gesture, as browsers require; its audio session is "ambient" where the browser has the Audio Session API
(Safari 17+), so a phone's silent switch mutes it and it mixes with whatever the player was already listening to instead of
stopping it; the OS's media keys (pause, stop) mute it while a recording plays, through the Media Session API; and when the
browser keeps the context from running after a gesture (a site set to block sound, or Firefox's autoplay policy saying
"disallowed"), the button shows that in red rather than pretending. There is no CSS media query for a sound preference
(Media Queries 5 has prefers-reduced-motion, -transparency, -contrast, -data, forced-colors; nothing for audio). */
function soundBlocked() { // the browser is keeping sound from playing, as far as the page can tell
if (SND.on && navigator.getAutoplayPolicy) { try { if (navigator.getAutoplayPolicy("audiocontext") === "disallowed") return true; } catch (e) {} }
return !!(SND.on && SND.ctx && SND.gesture && SND.ctx.state !== "running" && performance.now() - SND.gesture > 1500);
}
function renderMute() {
const b = el("mute"); if (!b) return;
const blocked = soundBlocked();
b.classList.toggle("off", !SND.on); b.classList.toggle("blocked", blocked);
b.setAttribute("aria-pressed", String(!SND.on));
b.setAttribute("aria-label", !SND.on ? "Unmute" : blocked ? "Sound is blocked by the browser" : "Mute");
b.title = !SND.on ? "Unmute (M)" : blocked ? "Your browser is not letting this page play sound: allow sound for it in the site settings, then press here or any key." : "Mute (M)";
const sw = el("soundToggle"), hb = el("hapticsToggle"); // the panel under the chevron: the switch, the volume while on, and haptics where the device can tick
sw.textContent = SND.on ? "Sound on" : "Sound off"; sw.setAttribute("aria-pressed", String(SND.on));
el("volRow").hidden = !SND.on; el("soundVol").value = String(Math.round(SND.vol * 100));
hb.hidden = !navigator.vibrate; hb.textContent = SND.haptics ? "Haptics on" : "Haptics off"; hb.setAttribute("aria-pressed", String(SND.haptics));
}
function soundPanel(open) {
const p = el("soundPanel"), t = el("soundMore");
p.hidden = !open; t.setAttribute("aria-expanded", String(open));
}
el("soundMore").onclick = () => soundPanel(el("soundPanel").hidden);
document.addEventListener("pointerdown", ev => { if (!el("soundPanel").hidden && !ev.target.closest("#soundPanel, #soundMore")) soundPanel(false); });
document.addEventListener("keydown", ev => { if (ev.key === "Escape" && !el("soundPanel").hidden) soundPanel(false); });
el("soundToggle").onclick = () => setSound(!SND.on);
el("soundVol").oninput = () => { setVolume(el("soundVol").value / 100); audio(); }; // (moving it is a gesture, so the sound may start here)
el("soundVol").onchange = () => drip(0.22); // and a drop, to hear the level
el("hapticsToggle").onclick = () => setHaptics(!SND.haptics);
el("mute").onclick = () => { if (SND.on && soundBlocked()) { audio(); renderMute(); return; } setSound(!SND.on); };
document.addEventListener("keydown", ev => {
if ((ev.key === "m" || ev.key === "M") && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) { ev.preventDefault(); setSound(!SND.on); }
});
if (navigator.audioSession) { try { navigator.audioSession.type = "ambient"; } catch (e) {} }
if (navigator.mediaSession) {
try {
navigator.mediaSession.setActionHandler("pause", () => setSound(false));
navigator.mediaSession.setActionHandler("stop", () => setSound(false));
navigator.mediaSession.setActionHandler("play", () => setSound(true));
} catch (e) {}
}
function setVolume(v) { // 0..1, remembered
SND.vol = Math.max(0, Math.min(1, v)); try { localStorage.setItem("samon-sound-vol", String(SND.vol)); } catch (e) {}
if (SND.master) SND.master.gain.setTargetAtTime(SND.vol, SND.ctx.currentTime, 0.03);
}
function setHaptics(on) { SND.haptics = on; try { localStorage.setItem("samon-haptics", on ? "1" : "0"); } catch (e) {} renderMute(); }
function haptic(ms = 8) { if (SND.haptics && navigator.vibrate) { try { navigator.vibrate(ms); } catch (e) {} } } // a tick per cell, where the device can
function loadPieces() { // the recordings carried in the page, those this browser can play
if (SND.tracks) return;
const probe = document.createElement("audio");
SND.tracks = [...document.querySelectorAll("script.piece")].map(el => ({ src: samonAsset.text(el), title: el.dataset.title || "", player: el.dataset.player || "",
license: el.dataset.license || "", source: el.dataset.source || "", mime: el.dataset.mime || "", gain: parseFloat(el.dataset.gain) || 0.35, dur: parseFloat(el.dataset.dur) || 0 }))
.filter(tr => tr.src && (!tr.mime || probe.canPlayType(tr.mime) !== ""));
if (!SND.tracks.length) SND.tracks = null;
}
function maybePiece(reason) { // after a world's bell, a piece follows; otherwise one now and then, never within a quarter-hour of the last
if (!SND.on || !SND.tracks || SND.cur) return;
if (reason !== "capstone" && (Date.now() - SND.lastPiece < 15 * 60000 || Math.random() > 0.3)) return;
const unplayed = SND.tracks.filter(tr => !SND.played.includes(tr.title)), pool = unplayed.length ? unplayed : SND.tracks;
const tr = pool[Math.floor(Math.random() * pool.length)];
later(() => playPiece(tr), reason === "capstone" ? 7000 : 2500);
}
function playPiece(tr, from = 0) { // from: seconds into the piece (tests)
const ctx = audio(); if (!ctx || SND.cur) return;
const a = new Audio(); a.preload = "auto";
let node; try { node = ctx.createMediaElementSource(a); } catch (e) { return; }
const g = ctx.createGain(); g.gain.value = 0; node.connect(g); g.connect(SND.out);
const cur = SND.cur = { a, g, tr }; SND.lastPiece = Date.now(); SND.played.push(tr.title);
if (navigator.mediaSession && window.MediaMetadata) { try { navigator.mediaSession.metadata = new MediaMetadata({ title: tr.title, artist: tr.player, album: "Samon" }); } catch (e) {} }
const vol = tr.gain || 0.35;
a.addEventListener("playing", () => { const t = ctx.currentTime; g.gain.cancelScheduledValues(t); g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(vol, t + 3); }, { once: true });
a.addEventListener("timeupdate", () => { if (a.duration && a.duration - a.currentTime < 4) g.gain.setValueAtTime(Math.max(0, vol * (a.duration - a.currentTime) / 4), ctx.currentTime); });
const done = () => { if (SND.cur === cur) SND.cur = null; try { node.disconnect(); } catch (e) {} if (a.src.startsWith("blob:")) URL.revokeObjectURL(a.src); };
a.addEventListener("ended", done); a.addEventListener("error", done);
if (from) a.addEventListener("loadedmetadata", () => { try { a.currentTime = from; } catch (e) {} }, { once: true });
samonAsset.url(tr.src, tr.mime).then(url => { if (SND.cur !== cur) return; a.src = url; return a.play(); }).catch(done);
}
function stopPiece() { const c = SND.cur; if (!c) return; SND.cur = null; try { const u = c.a.src; c.a.pause(); c.a.removeAttribute("src"); c.a.load(); if (u.startsWith("blob:")) URL.revokeObjectURL(u); } catch (e) {} }
document.addEventListener("visibilitychange", () => { if (document.hidden) stopPiece(); });
async function renderSound(script, secs) { // (test hook) render a sequence of the synthesized sounds offline, as 16-bit mono WAV
const O = window.OfflineAudioContext || window.webkitOfflineAudioContext, ctx = new O(1, Math.ceil(44100 * secs), 44100);
const keep = { ctx: SND.ctx, out: SND.out, master: SND.master, basin: SND.basin, meter: SND.meter, wind: SND.wind, scr: SND.scr, gravel: SND.gravel, on: SND.on, offline: null };
SND.on = true; SND.ctx = ctx; SND.offline = new Set(); SND.wind = SND.scr = SND.gravel = null; SND.master = null;
SND.out = ctx.createGain(); SND.out.gain.value = 0.8; SND.out.connect(ctx.destination);
const d = ctx.createDelay(0.3), fb = ctx.createGain(), lp = ctx.createBiquadFilter(), wet = ctx.createGain();
d.delayTime.value = 0.057; fb.gain.value = 0.4; lp.type = "lowpass"; lp.frequency.value = 1600; wet.gain.value = 0.45;
d.connect(lp); lp.connect(fb); fb.connect(d); lp.connect(wet); wet.connect(SND.out); SND.basin = d;
const F = { drip, tock, tok, bell, crunch, birds, crickets, gust, wind: (vol) => windStart(vol),
scrape: (dur = 3, bends = 2) => { // a whole pass of the rake: start, a steady run with a bend or two, stop (as revealTick drives it)
scrapeStart(); const n = Math.round(dur / 0.05);
for (let i = 0; i <= n; i++) { const f = i / n, v = 6 * (easeRake(Math.min(1, f + 0.01)) - easeRake(f)) / 0.01; // 6 cells/s at full speed
offlineAt(ctx.currentTime + i * 0.05, i < n ? () => scrapeSet(Math.max(0, v), bends && Math.abs(((f * (bends + 1)) % 1) - 0.5) < 0.08) : scrapeStop); }
} };
for (const [at, kind, ...args] of script) offlineAt(at, () => F[kind](...args)); // (each sound is scheduled with the render suspended at its moment, then rendering goes on)
const buf = await ctx.startRendering();
Object.assign(SND, keep);
const x = buf.getChannelData(0), n = x.length, w = new DataView(new ArrayBuffer(44 + n * 2));
const str = (o, t) => { for (let i = 0; i < t.length; i++) w.setUint8(o + i, t.charCodeAt(i)); };
str(0, "RIFF"); w.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); w.setUint32(16, 16, true); w.setUint16(20, 1, true); w.setUint16(22, 1, true);
w.setUint32(24, 44100, true); w.setUint32(28, 88200, true); w.setUint16(32, 2, true); w.setUint16(34, 16, true); str(36, "data"); w.setUint32(40, n * 2, true);
for (let i = 0; i < n; i++) { const v = Math.max(-1, Math.min(1, x[i])); w.setInt16(44 + i * 2, v < 0 ? v * 32768 : v * 32767, true); }
return Array.from(new Uint8Array(w.buffer));
}
/* ---------- drawing ---------- */
let geom = { ox: 0, oy: 0, cs: 40, w: 0, h: 0, wall: 10 };
let sandTex = null;
function resize() {
const css = cv.parentElement.clientWidth;
const dpr = Math.min(3, window.devicePixelRatio || 1);
const wallFrac = 0.32;
const Wc = view === "practice" ? PR.W : G.W, Hc = view === "practice" ? PR.H : G.H;
const cs = Math.floor((css * dpr) / (Wc + 2 * wallFrac));
const wall = Math.round(cs * wallFrac);
geom = { cs, wall, ox: wall, oy: wall, w: cs * Wc + 2 * wall, h: cs * Hc + 2 * wall };
cv.width = geom.w; cv.height = geom.h;
cv.style.aspectRatio = `${geom.w} / ${geom.h}`;
sandTex = null; draw();
}
let PAL = null;
function color(name) {
if (!PAL) PAL = {};
if (!(name in PAL)) PAL[name] = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return PAL[name];
}
function rng(seed) { let s = seed >>> 0; return () => ((s = Math.imul(s ^ (s >>> 15), 2246822507) ^ Math.imul(s ^ (s >>> 13), 3266489909)) >>> 0) / 4294967296; }
function makeSandTex() {
const t = document.createElement("canvas"); t.width = geom.w; t.height = geom.h;
const c = t.getContext("2d"); const R = rng(freshSeed());
// specks at device resolution over the (slightly upscaled) field: dark ones and a few light ones, soft and sparse
const n = Math.floor(geom.w * geom.h / 700), dark = hexRGB(color("--sand")).reduce((a, b) => a + b) < 384;
for (let i = 0; i < n; i++) {
const light = R() < 0.3;
c.fillStyle = light ? color("--groove-hi") : color("--sand-dot"); c.globalAlpha = (light ? (dark ? 0.06 : 0.16) : 0.28) + R() * (light && dark ? 0.1 : 0.28);
c.beginPath(); c.ellipse(R() * geom.w, R() * geom.h, 0.45 + R() * 0.6, 0.35 + R() * 0.45, R() * 3.14, 0, 6.283); c.fill();
}
return t;
}
function glyphFor(i, full) { // directions of the groove at path index i
const ds = new Set();
ds.add(backDir(i, full));
if (i + 1 < full.length) ds.add(dirBetween(full[i], full[i + 1]));
else if (G.endCell >= 0 && full[i] === G.endCell && G.end_side) ds.add(G.end_side);
return ds;
}
function grooveCell(c2, r, c, ds) {
const { ox, oy, cs } = geom; const x = ox + c * cs, y = oy + r * cs;
const d = [...ds];
for (const o of TINES) {
c2.beginPath();
if (d.length === 2 && ds.has("E") && ds.has("W")) { c2.moveTo(x, y + o * cs); c2.lineTo(x + cs, y + o * cs); }
else if (d.length === 2 && ds.has("N") && ds.has("S")) { c2.moveTo(x + o * cs, y); c2.lineTo(x + o * cs, y + cs); }
else if (d.length === 2) {
const cx = x + (ds.has("E") ? cs : 0), cy = y + (ds.has("S") ? cs : 0);
const a0 = ds.has("N") ? (ds.has("E") ? Math.PI / 2 : 0) : (ds.has("E") ? Math.PI : 1.5 * Math.PI);
c2.arc(cx, cy, o * cs, a0, a0 + Math.PI / 2);
} else {
const s = d[0];
if (s === "E") { c2.moveTo(x + cs, y + o * cs); c2.lineTo(x + 0.03 * cs, y + o * cs); }
if (s === "W") { c2.moveTo(x, y + o * cs); c2.lineTo(x + 0.97 * cs, y + o * cs); }
if (s === "S") { c2.moveTo(x + o * cs, y + cs); c2.lineTo(x + o * cs, y + 0.03 * cs); }
if (s === "N") { c2.moveTo(x + o * cs, y); c2.lineTo(x + o * cs, y + 0.97 * cs); }
}
c2.stroke();
}
}
function strokeGrooves(cells, alpha = 1, ghost = false) {
const lw = Math.max(1.2, geom.cs / 24);
ctx.save(); ctx.globalAlpha = alpha; ctx.lineCap = "butt";
if (!ghost) {
ctx.strokeStyle = color("--groove-hi"); ctx.lineWidth = lw * 1.25;
ctx.translate(-lw * 0.45, -lw * 0.45); for (const [r, c, ds] of cells) grooveCell(ctx, r, c, ds);
ctx.translate(lw * 0.45, lw * 0.45);
}
ctx.strokeStyle = color(ghost ? "--ghost" : "--groove"); ctx.lineWidth = lw;
for (const [r, c, ds] of cells) grooveCell(ctx, r, c, ds);
ctx.restore();
}
function stone(x, y, rad, seed, c2 = ctx) {
const R = rng(seed); const n = 9, ph = R() * 6.283; const pts = [];
for (let i = 0; i < n; i++) { const a = ph + i * 6.283 / n, rr = rad * (0.8 + 0.28 * R()); pts.push([x + rr * Math.cos(a), y + rr * Math.sin(a) * 0.86]); }
c2.beginPath();
for (let i = 0; i < n; i++) { const p = pts[i], q = pts[(i + 1) % n], m = [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
if (i === 0) { const z = pts[n - 1]; c2.moveTo((z[0] + p[0]) / 2, (z[1] + p[1]) / 2); }
c2.quadraticCurveTo(p[0], p[1], m[0], m[1]); }
c2.closePath();
}
/* Stones as sumi-e. A contour that is lumpy and a little rough, flatter at its base where it sits in the sand; a soft
contact shadow cast down and to the right (the sand's light is from the upper left), drawn as the shadow of one path so
the parts of a bent stone never darken each other; an ink wash, lighter toward the light; and a brushed edge, one band
of varying weight: heavy where the ink pools on the shadow side, thinning to nothing where the brush ran dry on the lit
side. All of it smooth: no segments, no steps. */
function contour(cx, cy, hx, hy, seed, p = 2, wob = 0.1) { // sampled points of the contour, with outward normals (p = 2 an oval; 3 a boxier stone)
const R = rng(seed), n = 72, ph = R() * 6.283, lumps = Array.from({ length: 7 }, () => (R() - 0.5) * 2), rough = Array.from({ length: 19 }, () => (R() - 0.5) * 2);
const smooth = (arr, u) => { const m = arr.length, k = u * m, i0 = Math.floor(k) % m, t = k - Math.floor(k), tt = t * t * (3 - 2 * t); return arr[i0] * (1 - tt) + arr[(i0 + 1) % m] * tt; };
const pts = [];
for (let i = 0; i < n; i++) {
const u = i / n, a = ph + u * 6.283, ca = Math.abs(Math.cos(a)), sa = Math.abs(Math.sin(a));
const re = Math.pow(Math.pow(ca / hx, p) + Math.pow(sa / hy, p), -1 / p); // the superellipse's radius this way
const rr = re * (1 + wob * smooth(lumps, u) + 0.018 * smooth(rough, u) + 0.03 * Math.sin(3 * a + ph));
let x = cx + rr * Math.cos(a), y = cy + rr * Math.sin(a);
if (Math.sin(a) > 0.5) y = cy + (y - cy) * (1 - 0.12 * (Math.sin(a) - 0.5) / 0.5); // the base sits lower and flatter
pts.push({ x, y });
}
for (let i = 0; i < n; i++) { // outward normals, from the neighbours
const a = pts[(i + n - 1) % n], b = pts[(i + 1) % n], tx = b.x - a.x, ty = b.y - a.y, l = Math.hypot(tx, ty) || 1;
pts[i].nx = ty / l; pts[i].ny = -tx / l;
}
return pts;
}
function contourPath(pts, dx = 0, dy = 0, k = 1, cx = 0, cy = 0) { // the contour as a smooth closed path, scaled about (cx, cy) and shifted
const P = new Path2D(), n = pts.length, Q = pts.map(q => [cx + (q.x - cx) * k + dx, cy + (q.y - cy) * k + dy]);
for (let i = 0; i < n; i++) {
const q = Q[i], w = Q[(i + 1) % n], m = [(q[0] + w[0]) / 2, (q[1] + w[1]) / 2];
if (i === 0) { const z = Q[n - 1]; P.moveTo((z[0] + q[0]) / 2, (z[1] + q[1]) / 2); }
P.quadraticCurveTo(q[0], q[1], m[0], m[1]);
}
P.closePath(); return P;
}
function isDark() { const c = hexRGB(color("--bg")); return c[0] + c[1] + c[2] < 384; }
function paintStone(parts, cs) { // parts: [{cx, cy, hx, hy, seed, p, wob}], one stone (a bent stone is two overlapping parts)
const pts = parts.map(q => contour(q.cx, q.cy, q.hx, q.hy, q.seed, q.p, q.wob));
const union = (dx, dy, k) => { const P = new Path2D(); parts.forEach((q, j) => P.addPath(contourPath(pts[j], dx, dy, k, q.cx, q.cy))); return P; };
const dark = isDark(), hi = hexRGB(color("--stone-hi")), lo = hexRGB(color("--stone")), rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a, b, t) => a.map((v, i) => Math.round(v * (1 - t) + b[i] * t));
const body = union(0, 0, 1);
const x0 = Math.min(...parts.map(q => q.cx - q.hx)), y0 = Math.min(...parts.map(q => q.cy - q.hy)), x1 = Math.max(...parts.map(q => q.cx + q.hx)), y1 = Math.max(...parts.map(q => q.cy + q.hy));
ctx.save();
ctx.fillStyle = color("--stone"); // the contact shadow: the body's own shadow, soft and cast down-right, and a tighter darker one at the base
ctx.shadowColor = `rgba(0,0,0,${dark ? 0.55 : 0.32})`; ctx.shadowBlur = cs * 0.16; ctx.shadowOffsetX = cs * 0.08; ctx.shadowOffsetY = cs * 0.11; ctx.fill(body);
ctx.shadowColor = `rgba(0,0,0,${dark ? 0.6 : 0.4})`; ctx.shadowBlur = cs * 0.05; ctx.shadowOffsetX = cs * 0.02; ctx.shadowOffsetY = cs * 0.035; ctx.fill(body);
ctx.shadowColor = "transparent"; ctx.shadowBlur = 0; ctx.shadowOffsetX = ctx.shadowOffsetY = 0;
const g = ctx.createLinearGradient(x0 + (x1 - x0) * 0.1, y0, x1, y1); // the wash: pale where the light falls, the mass of the ink below
g.addColorStop(0, rgba(mix(hi, lo, dark ? 0 : 0.1), 1)); g.addColorStop(0.3, rgba(mix(hi, lo, 0.6), 1)); g.addColorStop(0.62, color("--stone")); g.addColorStop(1, rgba(mix(lo, [0, 0, 0], 0.35), 1));
ctx.fillStyle = g; ctx.fill(body);
// the ink edge, one band astride the contour: its weight follows the light (heavy on the shadow side), wanders a little,
// and thins to nothing through one or two breaks on the lit side where the brush ran dry; in dark mode it is the pale rim
// light instead, and so heavy on the lit side
const base = Math.max(1, cs / 30), paths = parts.map((q, j) => contourPath(pts[j], 0, 0, 1, q.cx, q.cy));
ctx.fillStyle = dark ? color("--stone-hi") : "#000";
parts.forEach((q, j) => {
const R = rng(q.seed + 101), P = pts[j], n = P.length, w0 = R() * 6.283, w1 = R() * 6.283;
const breaks = Array.from({ length: 1 + Math.floor(R() * 2) }, () => [R() * 6.283, 0.35 + R() * 0.45]); // [where, half-width] in angle
const widths = P.map((pt, i) => {
const facing = (1 + (-0.6 * pt.nx - 0.8 * pt.ny)) / 2, lit = dark ? 1 - facing : facing; // 1 where the edge is thin
let w = base * (0.25 + 2.1 * Math.pow(1 - lit, 1.4)) * (1 + 0.3 * Math.sin(w0 + i * 0.35) * Math.cos(w1 + i * 0.13));
const ang = Math.atan2(pt.ny, pt.nx);
for (const [at, hw] of breaks) { const d = Math.abs(((ang - at + 9.4248) % 6.2832) - 3.1416); if (d < hw) w *= Math.min(1, Math.pow(d / hw, 2) * (0.4 + 1.6 * (1 - lit))); } // the dry stretch, deeper where lit
if (paths.some((o, k) => k !== j && ctx.isPointInPath(o, pt.x, pt.y))) w = 0; // inside the other part of a bent stone: no edge
return w;
});
const sm = widths.map((w, i) => (widths[(i + n - 1) % n] + 2 * w + widths[(i + 1) % n]) / 4); // (smoothed once)
const outer = P.map((pt, i) => [pt.x + pt.nx * sm[i] * 0.6, pt.y + pt.ny * sm[i] * 0.6]), inner = P.map((pt, i) => [pt.x - pt.nx * sm[i] * 0.4, pt.y - pt.ny * sm[i] * 0.4]);
const band = new Path2D();
outer.forEach((pt, i) => i ? band.lineTo(pt[0], pt[1]) : band.moveTo(pt[0], pt[1])); band.closePath();
const hole = new Path2D(); inner.forEach((pt, i) => i ? hole.lineTo(pt[0], pt[1]) : hole.moveTo(pt[0], pt[1])); hole.closePath();
band.addPath(hole);
ctx.globalAlpha = dark ? 0.9 : 0.95; ctx.fill(band, "evenodd");
});
ctx.restore();
}
function inkStone(X, Y, rad, seed) { paintStone([{ cx: X, cy: Y, hx: rad, hy: rad * 0.88, seed, p: 2, wob: 0.13 }], rad / 0.41); }
function frame() {
const { w, h, wall } = geom;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = color("--wall"); ctx.beginPath(); ctx.roundRect ? ctx.roundRect(0, 0, w, h, Math.max(2, wall * 0.2)) : ctx.rect(0, 0, w, h); ctx.fill();
}
function stoneGroups() { // 4-connected groups of stone cells (# and O; a ripple stone is always alone)
if (G.groups) return G.groups;
const seen = new Set(), out = [];
for (let r = 0; r < G.H; r++) for (let c = 0; c < G.W; c++) {
const s = G.rows[r][c];
if ((s !== "#" && s !== "O") || seen.has(r * G.W + c)) continue;
const comp = [], stack = [[r, c]]; seen.add(r * G.W + c);
while (stack.length) {
const [a, b] = stack.pop(); comp.push([a, b]);
if (G.rows[a][b] === "O") continue;
for (let d = 0; d < 4; d++) { const aa = a + DR[d], bb = b + DC[d];
if (aa >= 0 && aa < G.H && bb >= 0 && bb < G.W && G.rows[aa][bb] === "#" && !seen.has(aa * G.W + bb)) { seen.add(aa * G.W + bb); stack.push([aa, bb]); } }
}
out.push(comp);
}
return (G.groups = out);
}
function cutOffCells() {
if (!path.length) return [];
const S = stateOf(path); reachable(S);
return G.sandList.filter(v => !S.vis[v] && !BSEEN[v]);
}
function draw() {
if (view === "practice") { drawPractice(); return; }
if (!G || !PZ) return;
const { ox, oy, cs } = geom;
frame();
// gate gaps
const gap = (r, c, side) => {
const x = ox + c * cs, y = oy + r * cs, m = cs * 0.1, wall = geom.wall;
if (side === "S") return [x + m, y + cs, cs - 2 * m, wall];
if (side === "N") return [x + m, y - wall, cs - 2 * m, wall];
if (side === "W") return [x - wall, y + m, wall, cs - 2 * m];
return [x + cs, y + m, wall, cs - 2 * m];
};
const [gx, gy, gw, gh] = gap(G.gate[0], G.gate[1], G.gate[2]);
ctx.fillStyle = color("--sand"); ctx.fillRect(gx, gy, gw, gh);
let sx, sy, sw, sh;
if (G.end) { [sx, sy, sw, sh] = gap(G.end[0], G.end[1], G.end_side); ctx.fillRect(sx, sy, sw, sh); }
// the sand itself: a lit heightfield (footprints, the abbot's grooves, the rake's), with crisp grain on top
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
ctx.drawImage(PZ.cvs, ox, oy, cs * G.W, cs * G.H);
if (!sandTex) sandTex = makeSandTex();
ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, cs * G.W, cs * G.H); ctx.clip(); ctx.globalAlpha = 0.4; ctx.drawImage(sandTex, 0, 0); ctx.restore();
// two-tone gravel (the parity lesson)
if (G.tone > 0) {
ctx.save(); ctx.fillStyle = color("--fg"); ctx.globalAlpha = 0.13 * G.tone;
for (const v of G.sandList) if (G.black[v]) { const [r, c] = rc(v); ctx.fillRect(ox + c * cs, oy + r * cs, cs, cs); }
ctx.restore();
}
// assists: checkerboard and cut-off sand
if (mode === "draw" && (assists.checker || marks.checker)) {
ctx.save(); ctx.fillStyle = color("--fg"); ctx.globalAlpha = 0.075;
for (const v of G.sandList) if (G.black[v]) { const [r, c] = rc(v); ctx.fillRect(ox + c * cs, oy + r * cs, cs, cs); }
ctx.restore();
}
if (mode === "draw" && assists.cutoff) {
const cells = cutOffCells();
if (cells.length) {
ctx.save(); ctx.strokeStyle = color("--fg"); ctx.globalAlpha = 0.35; ctx.lineWidth = Math.max(1, cs / 30);
for (const v of cells) { const [r, c] = rc(v); const x = ox + c * cs, y = oy + r * cs;
ctx.save(); ctx.beginPath(); ctx.rect(x, y, cs, cs); ctx.clip(); ctx.beginPath();
for (let o = -cs; o < cs; o += cs / 5) { ctx.moveTo(x + o, y + cs); ctx.lineTo(x + o + cs, y); } ctx.stroke(); ctx.restore(); }
ctx.restore();
}
}
// sketch marks: bands, ripple rings
const markC = color("--mark");
if (mode === "draw") {
ctx.save(); ctx.strokeStyle = markC; ctx.globalAlpha = 0.75; ctx.lineWidth = Math.max(1, cs / 44); ctx.setLineDash([cs / 14, cs / 10]);
for (let r = 0; r < G.H; r++) for (let c = 0; c < G.W; c++) {
const s = G.rows[r][c], x = ox + c * cs, y = oy + r * cs;
ctx.beginPath();
if (s === "h" || s === "=") for (const o of [0.25, 0.5, 0.75]) { ctx.moveTo(x, y + o * cs); ctx.lineTo(x + cs, y + o * cs); }
if (s === "v" || s === "|") for (const o of [0.25, 0.5, 0.75]) { ctx.moveTo(x + o * cs, y); ctx.lineTo(x + o * cs, y + cs); }
ctx.stroke();
}
ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.lineWidth = Math.max(1.2, cs / 30);
for (let r = 0; r < G.H; r++) for (let c = 0; c < G.W; c++) {
const s = G.rows[r][c]; if (!"hv=|".includes(s)) continue;
const x = ox + c * cs, y = oy + r * cs; ctx.beginPath();
if (sym(G, r - 1, c) !== s) { ctx.moveTo(x, y); ctx.lineTo(x + cs, y); }
if (sym(G, r + 1, c) !== s) { ctx.moveTo(x, y + cs); ctx.lineTo(x + cs, y + cs); }
if (sym(G, r, c - 1) !== s) { ctx.moveTo(x, y); ctx.lineTo(x, y + cs); }
if (sym(G, r, c + 1) !== s) { ctx.moveTo(x + cs, y); ctx.lineTo(x + cs, y + cs); }
ctx.stroke();
}
ctx.setLineDash([cs / 22, cs / 9]); ctx.lineWidth = Math.max(1, cs / 36);
for (let r = 0; r < G.H; r++) for (let c = 0; c < G.W; c++) if (G.rows[r][c] === "O") {
for (const rad of [0.95, 1.25]) { ctx.beginPath(); ctx.arc(ox + c * cs + cs / 2, oy + r * cs + cs / 2, rad * cs, 0, 6.283); ctx.stroke(); }
}
ctx.restore();
}
// the abbot's sketched grooves (hints), and a preview of your own
if (mode === "draw") {
const ghostCells = [...ghosts].filter(([k]) => !path.includes(k)).map(([k, ds]) => { const [r, c] = rc(k); return [r, c, ds]; });
if (ghostCells.length) strokeGrooves(ghostCells, 0.95, true);
if (assists.preview && path.length) strokeGrooves(path.map((k, i) => { const [r, c] = rc(k); return [r, c, glyphFor(i, path)]; }), 0.6, true);
}
// where the rake is, during the reveal
let rk = null, passed = new Set();
if (mode === "reveal" && REV) {
rk = strokePoint(REV.st, REV.s);
for (let j = 0; j < rk.k; j++) passed.add(REV.st.segs[j].cell);
}
// the abbot's pebbles (small irregular stones, unlike the monk's ensō): pale = straight through, dark = turn; the rake gathers them
if (mode !== "done") for (const v of G.markList) {
if (passed.has(v)) continue;
const [r, c] = rc(v); const X = ox + c * cs + cs / 2 + cs * 0.17, Y = oy + r * cs + cs / 2 - cs * 0.17, rad = cs * 0.13, seed = v * 7919 + 3;
ctx.save();
ctx.fillStyle = color("--sand-dot"); stone(X + rad * 0.15, Y + rad * 0.2, rad * 1.08, seed); ctx.fill(); // its shadow
stone(X, Y, rad, seed);
if (G.mark[v] === 1) { ctx.fillStyle = color("--groove-hi"); ctx.fill(); ctx.lineWidth = Math.max(1.2, cs / 30); ctx.strokeStyle = color("--stone"); ctx.stroke(); }
else { ctx.fillStyle = color("--stone"); ctx.fill(); }
ctx.restore();
}
// the cells the abbot pointed at with his observation: a dotted ring, until another garden
if (mode === "draw" && obs.length) {
ctx.save(); ctx.strokeStyle = color("--fg"); ctx.globalAlpha = 0.8; ctx.lineWidth = Math.max(1.5, cs / 24); ctx.setLineDash([cs / 30, cs / 14]);
for (const v of obs) { const [r, c] = rc(v); ctx.beginPath(); ctx.arc(ox + c * cs + cs / 2, oy + r * cs + cs / 2, cs * 0.42, 0, 6.283); ctx.stroke(); }
ctx.restore();
}
// hint highlights
if (mode === "draw" && marks.cells.length) {
ctx.save(); ctx.strokeStyle = color("--fg"); ctx.fillStyle = color("--fg"); ctx.lineWidth = Math.max(1.5, cs / 22); ctx.setLineDash([cs / 9, cs / 12]);
for (const v of marks.cells) { const [r, c] = rc(v); const x = ox + c * cs, y = oy + r * cs, m = cs * 0.1;
ctx.globalAlpha = 0.08; ctx.fillRect(x + m, y + m, cs - 2 * m, cs - 2 * m); ctx.globalAlpha = 0.9; ctx.strokeRect(x + m, y + m, cs - 2 * m, cs - 2 * m); }
ctx.restore();
}
// the faint route through the footprints (it fades as the monk sets down his rake)
const routeA = (mode === "draw" || (mode === "demo" && !REV)) ? 0.22 : mode === "reveal" && REV ? 0.22 * Math.max(0, 1 - (performance.now() - REV.t0) / 450) : 0;
if (path.length > 1 && routeA > 0) {
ctx.save(); ctx.strokeStyle = color("--fg"); ctx.globalAlpha = routeA; ctx.lineWidth = Math.max(1.5, cs / 16); ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.beginPath(); path.forEach((k, i) => { const [r, c] = rc(k); const X = ox + c * cs + cs / 2, Y = oy + r * cs + cs / 2; i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); }); ctx.stroke(); ctx.restore();
}
if (mode === "draw" && marks.target >= 0 && path.includes(marks.target)) {
const [r, c] = rc(marks.target); ctx.save(); ctx.strokeStyle = color("--fg"); ctx.lineWidth = Math.max(2, cs / 16);
ctx.beginPath(); ctx.arc(ox + c * cs + cs / 2, oy + r * cs + cs / 2, cs * 0.36, 0, 6.283); ctx.stroke(); ctx.restore();
}
// stones: a stone of several cells is drawn as one stone
for (const comp of stoneGroups()) {
const seed = idx * 131 + comp[0][0] * 17 + comp[0][1];
if (comp.length === 1) { const [r, c] = comp[0]; inkStone(ox + c * cs + cs / 2, oy + r * cs + cs / 2, cs * 0.41, seed); continue; }
// one stone, not several touching ones: an irregular elongated stone along each straight run of cells
// (a straight piece is one long stone; an L-shaped piece, two that share the corner cell, which reads as one bent stone)
const inG = new Set(comp.map(([r, c]) => r * G.W + c)), has = (r, c) => r >= 0 && c >= 0 && r < G.H && c < G.W && inG.has(r * G.W + c);
const runs = [];
for (const [r, c] of comp) {
if (!has(r, c - 1) && has(r, c + 1)) { let e = c; while (has(r, e + 1)) e++; runs.push([r, c, r, e]); }
if (!has(r - 1, c) && has(r + 1, c)) { let e = r; while (has(e + 1, c)) e++; runs.push([r, c, e, c]); }
}
paintStone(runs.map(([r1, c1, r2, c2], j) => { // each straight run fills its cells but for a thin bed of sand; the runs are one stone
const along = ((Math.max(r2 - r1, c2 - c1) + 1) / 2 - 0.07) * cs, across = 0.44 * cs;
return { cx: ox + (c1 + c2 + 1) / 2 * cs, cy: oy + (r1 + r2 + 1) / 2 * cs, hx: c2 > c1 ? along : across, hy: r2 > r1 ? along : across, seed: seed + j, p: 3, wob: 0.1 };
}), cs);
}
// gate markers
const tri = (x, y, rot, filled) => {
ctx.save(); ctx.translate(x, y); ctx.rotate(rot); const s = Math.max(5, cs * 0.12);
ctx.beginPath(); ctx.moveTo(-s, -s * 0.6); ctx.lineTo(s, -s * 0.6); ctx.lineTo(0, s * 0.8); ctx.closePath();
ctx.lineWidth = Math.max(1.5, cs / 28); ctx.strokeStyle = color("--seal"); ctx.fillStyle = color("--seal");
filled ? ctx.fill() : ctx.stroke(); ctx.restore();
};
const rotOut = { S: 0, N: Math.PI, W: Math.PI / 2, E: -Math.PI / 2 };
if (G.end) tri(sx + sw / 2, sy + sh / 2, rotOut[G.end_side] + Math.PI, false); // (the gate itself needs none: the gap shows it, and the monk waits in it)
// the monk (and, as he walks backwards, the rake he pulls)
const monkAt = (X, Y) => enso(X, Y, cs * 0.24);
if ((mode === "draw" || (mode === "demo" && !REV)) && path.length) { const [r, c] = rc(path[path.length - 1]); monkAt(ox + c * cs + cs / 2, oy + r * cs + cs / 2); }
if (rk) {
const m = strokePoint(REV.st, REV.s + 0.5), X = ox + rk.x * cs, Y = oy + rk.y * cs, MX = ox + m.x * cs, MY = oy + m.y * cs;
const nx = -rk.ty, ny = rk.tx, half = RAKE_W / 2 * cs;
const now = performance.now();
ctx.save(); ctx.fillStyle = color("--stone-hi");
for (const g of REV.grains) {
const age = (now - g.t0) / 1000, k = 1 - age / (g.life / 1000);
if (k <= 0) continue;
ctx.globalAlpha = 0.75 * k; ctx.beginPath();
ctx.arc(ox + (g.x + g.vx * age * (1 - age)) * cs, oy + (g.y + g.vy * age * (1 - age)) * cs, Math.max(0.8, g.r * cs), 0, 6.283); ctx.fill();
}
ctx.restore();
ctx.save(); ctx.strokeStyle = color("--fg"); ctx.lineCap = "round";
ctx.globalAlpha = 0.55; ctx.lineWidth = Math.max(1.5, cs / 30); // the handle, from the rake to the monk's hands
ctx.beginPath(); ctx.moveTo(X, Y); ctx.lineTo(MX, MY); ctx.stroke();
ctx.globalAlpha = 0.85; ctx.lineWidth = Math.max(2, cs / 15); // the rake's head
ctx.beginPath(); ctx.moveTo(X - nx * half, Y - ny * half); ctx.lineTo(X + nx * half, Y + ny * half); ctx.stroke();
ctx.lineWidth = Math.max(1.2, cs / 34); // and its tines, trailing in the sand
ctx.beginPath();
for (const o of [-0.3, 0, 0.3]) { const tx0 = X + nx * o * cs, ty0 = Y + ny * o * cs; ctx.moveTo(tx0, ty0); ctx.lineTo(tx0 - rk.tx * cs * 0.09, ty0 - rk.ty * cs * 0.09); }
ctx.stroke(); ctx.restore();
monkAt(MX, MY);
}
if (!path.length && mode === "draw") monkAt(...gatePoint()); // before the walk, the monk waits in the gateway: drag him in
syncBeckon();
}
/* The monk is an ensō: one brushed circle, landing heavy, thinning as it goes round, and left open before it closes, with a
dry end. It is painted once per size into a small canvas (its wobble is seeded, so it is the same monk every frame). */
let ensoCache = null;
function enso(X, Y, rad) { // rubricated: the novice is the one vermilion thing in the sand (the seal is the other)
const key = Math.round(rad * 4) + ":" + color("--seal");
if (!ensoCache || ensoCache.key !== key) ensoCache = { key, img: makeEnso(rad, color("--seal")) };
const img = ensoCache.img; ctx.drawImage(img, X - img.width / 2, Y - img.height / 2);
}
function makeEnso(rad, ink) {
const pad = rad * 0.7, size = Math.ceil(2 * (rad + pad)), c = document.createElement("canvas"); c.width = c.height = size;
const g = c.getContext("2d"), cx = size / 2, cy = size / 2, a0 = -2.35, span = 5.5, n = 96, outer = [], inner = [];
for (let i = 0; i <= n; i++) { // from the upper left, round clockwise, to a gap at the upper right
const t = i / n, a = a0 + span * t;
const land = Math.min(1, t / 0.07), lift = Math.min(1, (1 - t) / 0.08); // the brush comes down and lifts: rounded, not cut
const w = rad * (0.26 * Math.pow(1 - t, 0.75) + 0.07) * Math.sqrt(land) * Math.sqrt(lift) * (1 + 0.08 * Math.sin(t * 11 + 1)); // heavy at the landing, thinning
const r = rad * (1 + 0.035 * Math.sin(t * 6.1 + 0.4) + 0.015 * Math.sin(t * 10.7 + 2)); // the hand's slow wobble
outer.push([cx + (r + w / 2) * Math.cos(a), cy + (r + w / 2) * Math.sin(a)]);
inner.push([cx + (r - w / 2) * Math.cos(a), cy + (r - w / 2) * Math.sin(a)]);
}
g.fillStyle = ink; g.beginPath();
outer.forEach((p, i) => i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1]));
for (let i = n; i >= 0; i--) g.lineTo(inner[i][0], inner[i][1]);
g.closePath(); g.fill();
g.strokeStyle = ink; g.lineCap = "round"; // the dry end: two thin strands carrying on past the lift, fading
for (const [off, len, al, lw] of [[0.02, 0.36, 0.55, 0.045], [-0.06, 0.24, 0.3, 0.03]]) {
const a1 = a0 + span - 0.06, r1 = rad * (1 + off), lg = g.createLinearGradient(cx + r1 * Math.cos(a1), cy + r1 * Math.sin(a1), cx + r1 * Math.cos(a1 + len), cy + r1 * Math.sin(a1 + len));
const rgb = hexRGB(ink); lg.addColorStop(0, ink); lg.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`);
g.strokeStyle = lg; g.globalAlpha = al; g.lineWidth = Math.max(0.8, rad * lw);
g.beginPath(); for (let k = 0; k <= 8; k++) { const a = a1 + len * k / 8; k ? g.lineTo(cx + r1 * Math.cos(a), cy + r1 * Math.sin(a)) : g.moveTo(cx + r1 * Math.cos(a), cy + r1 * Math.sin(a)); } g.stroke();
}
return c;
}
function gatePoint() { // the veranda gate's threshold (where the gap meets the sand), in canvas pixels
const { ox, oy, cs } = geom, [r, c, side] = G.gate, x = ox + c * cs, y = oy + r * cs;
return side === "S" ? [x + cs / 2, y + cs] : side === "N" ? [x + cs / 2, y] : side === "W" ? [x, y + cs / 2] : [x + cs, y + cs / 2];
}
let beckonKey = "";
function syncBeckon() { // the ring around the waiting monk, and the ghostly finger that shows the first drag
if (demoRun || mode === "demo" || (mode === "reveal" && REV && REV.demo)) { el("beckon").hidden = true; beckonKey = "demo"; return; }
const show = view === "puzzle" && mode === "draw" && !path.length && !!G;
const demo = show && (idx === 0 || !started || demoOnce) && !(window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches);
const key = show ? [idx, geom.cs, cv.clientWidth, cv.offsetLeft, cv.offsetTop, demo].join() : "";
if (key === beckonKey) return;
beckonKey = key;
const b = el("beckon"), f = el("finger");
if (f.anim) { f.anim.cancel(); f.anim = null; }
b.hidden = !show; f.hidden = !demo;
if (!show) return;
const sc = cv.clientWidth / cv.width, at = ([X, Y]) => [cv.offsetLeft + X * sc, cv.offsetTop + Y * sc];
const [bx, by] = at(gatePoint());
b.style.left = bx + "px"; b.style.top = by + "px"; b.style.width = b.style.height = geom.cs * 0.95 * sc + "px";
if (!demo || !f.animate) return;
const cells = (G.parK[0] || []).slice(0, 4).map(k => { const [r, c] = rc(k); return at([geom.ox + c * geom.cs + geom.cs / 2, geom.oy + r * geom.cs + geom.cs / 2]); });
const pts = [[bx, by], ...cells], tr = ([x, y]) => `translate(${x}px, ${y}px)`;
const kf = [{ transform: tr(pts[0]), opacity: 0, offset: 0 }, { transform: tr(pts[0]), opacity: 0.95, offset: 0.12 }];
pts.slice(1).forEach((p, i) => kf.push({ transform: tr(p), opacity: 0.95, offset: 0.2 + 0.55 * (i + 1) / (pts.length - 1) }));
kf.push({ transform: tr(pts[pts.length - 1]), opacity: 0, offset: 0.92 }, { transform: tr(pts[pts.length - 1]), opacity: 0, offset: 1 });
f.anim = f.animate(kf, { duration: 2800, iterations: 3, easing: "ease-in-out", fill: "forwards" });
}
function update() {
if (view !== "puzzle") return;
el("turns").textContent = turnsOf();
el("undo").disabled = !((mode === "done" && (!isDaily() || shared)) || (mode === "draw" && path.length));
el("restart").disabled = mode === "done" && isDaily() && !shared;
el("rakeDay").hidden = !dailyReady();
el("hint").disabled = mode !== "draw";
el("watch").hidden = !(GARDENS[idx] && GARDENS[idx].demo) || (mode !== "draw" && mode !== "done");
el("restore").hidden = !(cut && mode === "draw");
draw();
}
/* Messages to the player (the status line, the help, the notes in the list and on the scroll) are editorial, and set in
square brackets, as gwern.net sets what the editor says to the reader; rules, poems, counts and controls are not. */
let statusRaw = "", statusWarn = false;
function setStatus(msg, warn = false) { statusRaw = msg; statusWarn = warn; const s = el("status"); s.textContent = msg ? `[${msg}]` : ""; s.className = "status" + (warn ? " warn" : ""); }
function appendStatus(more) { setStatus(statusRaw + more, statusWarn); }
function statusText() { return statusRaw; }
const extrasOpen = new Set(); // worlds whose extra gardens the player has unfolded in the list
function renderNav() {
const nav = el("nav"); nav.innerHTML = "";
const pill = (label, current, cls, title, fn) => {
const b = document.createElement("button"); b.type = "button"; b.className = "pill " + cls;
if (current) b.setAttribute("aria-current", "true");
b.innerHTML = `<span class="dot" aria-hidden="true"></span>${escapeHtml(label)}`; b.title = title; b.onclick = fn; return b;
};
/* Each open world: its main path as pips, and its extras behind a toggle ("+10 more"), unfolded while an extra of that
world is the garden open, or when the player unfolds it. A locked world is one muted line: its count and what opens it. */
for (let wld = 1; wld <= 7; wld++) {
const items = GARDENS.map((g, i) => [g, i]).filter(([g]) => g.world === wld);
if (!items.length) continue;
const row = document.createElement("div"); row.className = "world";
row.innerHTML = `<span class="t">${TIMES[wld]}</span>`;
const pills = document.createElement("div"); pills.className = "pills";
const open = worldOpen(wld);
if (!open) {
row.classList.add("locked");
const note = document.createElement("span"); note.className = "lock";
note.innerHTML = `<span class="n">${items.length} gardens ·</span> <span class="note"></span>`;
note.querySelector(".note").textContent = worldOpen(wld - 1) ? `[Opens when ${needIn(wld - 1)} gardens of ${TIMES[wld - 1]} are raked (${rakedIn(wld - 1)} so far; extras count).]` : "[Locked.]";
note.title = "Worlds open in turn, each a time of day; the gardens of a world not yet open are not shown";
pills.appendChild(note); row.appendChild(pills); nav.appendChild(row); continue;
}
const extras = items.filter(([g]) => g.extra), here = view === "puzzle" && GARDENS[idx] && GARDENS[idx].world === wld && GARDENS[idx].extra;
const unfolded = extras.length && (extrasOpen.has(wld) || here);
let n = 0, m = 0;
items.forEach(([g, i]) => {
if (g.extra && !unfolded) return;
const p = progress[g.name] || {};
if (g.extra && !m) { const sp = document.createElement("span"); sp.className = "more"; sp.textContent = "more:"; sp.title = "Extra gardens of this world: beside its main path, and counting toward opening the next world"; pills.appendChild(sp); }
const b = document.createElement("button"); b.type = "button";
b.className = "pip " + (g.extra ? "extra " : "") + (p.sealed ? "sealed" : p.grey ? "grey" : p.done ? "done" : "");
if (view === "puzzle" && i === idx) b.setAttribute("aria-current", "true");
b.textContent = g.extra ? ++m : ++n; b.setAttribute("aria-label", g.name);
b.title = `${g.name}: ` + (p.sealed ? "sealed" : p.grey ? "sealed in grey (raked at par with the abbot's help)" : p.done ? `raked (best ${p.best} turns)` : "not yet raked");
b.onclick = () => pickGarden(i);
pills.appendChild(b);
});
if (extras.length) {
const raked = extras.filter(([g]) => (progress[g.name] || {}).done).length;
const t = document.createElement("button"); t.type = "button"; t.className = "fold"; t.setAttribute("aria-expanded", String(!!unfolded));
t.textContent = unfolded ? "fewer" : `+${extras.length} more`;
t.title = unfolded ? "Fold the extra gardens away" : `${extras.length} extra gardens beside this world's main path (${raked} raked); they count toward opening the next world`;
t.onclick = () => { if (unfolded) extrasOpen.delete(wld); else extrasOpen.add(wld); renderNav(); };
pills.appendChild(t);
}
row.appendChild(pills); nav.appendChild(row);
}
const row = document.createElement("div"); row.className = "world";
row.innerHTML = `<span class="t">Any time</span>`;
const pills = document.createElement("div"); pills.className = "pills";
if (DAILY.length) {
const di = ensureDaily(), rec = progress[GARDENS[di].name] || {}, streak = dailyStreak();
pills.appendChild(pill(`Today's garden${streak ? ` · streak ${streak}` : ""}`, view === "puzzle" && idx === di, rec.sealed ? "sealed" : rec.grey ? "grey" : rec.done ? "done" : "",
rec.done ? `Raked today: ${rec.turns} turns, par ${rec.par}` : "One garden for everyone today, raked once", pickDaily));
}
const earned = GARDENS.filter(g => !g.daily && (progress[g.name] || {}).done).length;
pills.appendChild(pill(`Poems · ${earned} of ${NCAMP}`, view === "poems", "", "Every poem you have earned, with its Japanese and its source", () => { enterPoems(); }));
pills.appendChild(pill("Practice garden", view === "practice", "", "Rake freely: no goal, no score", () => { enterPractice(); showStage(); }));
const cp = pill("Carry my progress", false, "", "A link that carries all your progress to another device or browser: open it there and take it over", carryProgress);
cp.id = "carry"; pills.appendChild(cp);
row.appendChild(pills); nav.appendChild(row); // (sound, volume and haptics live under the speaker's chevron in the header)
const oa = document.createElement("button"); oa.type = "button"; oa.className = "openall"; oa.id = "openAll";
oa.textContent = openAll ? "Lock the worlds again (prototype)" : "Open all worlds (prototype)";
oa.title = openAll ? "Close the worlds not yet earned, as a player would find them" : "A convenience of the prototype: every world open at once, without raking the ones before";
oa.onclick = () => { openAll = !openAll; try { localStorage.setItem("samon-open-all", openAll ? "1" : "0"); } catch (e) {} renderNav(); };
nav.appendChild(oa);
}
function syncAssists() {
for (const [id, k] of [["aPreview", "preview"], ["aChecker", "checker"], ["aCut", "cutoff"]]) el(id).setAttribute("aria-pressed", String(!!assists[k]));
}
for (const [id, k] of [["aPreview", "preview"], ["aChecker", "checker"], ["aCut", "cutoff"]])
el(id).onclick = () => { assists[k] = !assists[k]; saveAssists(); syncAssists(); draw(); };
/* ---------- sand as a lit heightfield: the gardens and the practice garden share it ---------- */
function hexRGB(s) {
s = (s || "").trim();
if (/^#[0-9a-f]{3}$/i.test(s)) s = "#" + [...s.slice(1)].map(ch => ch + ch).join("");
const m = /^#([0-9a-f]{6})$/i.exec(s);
if (!m) return [200, 200, 200];
const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function fieldRes(Wc) { // field samples per cell: about one per device pixel (so the sand is never upscaled much), within 32..144
const css = cv.parentElement.clientWidth, dpr = Math.min(3, window.devicePixelRatio || 1);
return Math.max(32, Math.min(144, Math.floor((css * dpr) / (Wc + 0.64))));
}
const freshSeed = () => (Math.random() * 4294967296) >>> 0; // the sand is laid out afresh each time: no two visits to a garden share its grain
function makeField(Wc, Hc, res, seed) {
const w = Wc * res, h = Hc * res, R = rng(seed), sc = res / 32;
const F = { Wc, Hc, res, sc, w, h, seed, hf: new Float32Array(w * h), base: new Float32Array(w * h), grain: new Float32Array(w * h), fine: new Float32Array(w * h), dirty: null, feet: null };
F.cvs = document.createElement("canvas"); F.cvs.width = w; F.cvs.height = h; F.c2 = F.cvs.getContext("2d");
F.img = F.c2.createImageData(w, h);
// height grain: two octaves (32 and 64 per cell) of value noise, interpolated, so the gravel reads the same at any resolution;
// and a per-sample albedo grain (the fleck of individual grains, in the colour only, so the lighting stays smooth)
for (const [per, amp] of [[32, 0.07], [64, 0.035]]) {
const lw = Wc * per + 2, lh = Hc * per + 2, lat = new Float32Array(lw * lh), k = res / per;
for (let i = 0; i < lat.length; i++) lat[i] = (R() - 0.5) * amp;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const fx = x / k, fy = y / k, ix = Math.floor(fx), iy = Math.floor(fy), ux = fx - ix, uy = fy - iy, tx = ux * ux * (3 - 2 * ux), ty = uy * uy * (3 - 2 * uy);
F.grain[y * w + x] += (lat[iy * lw + ix] * (1 - tx) + lat[iy * lw + ix + 1] * tx) * (1 - ty) + (lat[(iy + 1) * lw + ix] * (1 - tx) + lat[(iy + 1) * lw + ix + 1] * tx) * ty;
}
}
for (let i = 0; i < w * h; i++) { const u = R(); F.fine[i] = (u - 0.5) * (u < 0.04 ? 0.14 : 0.05); } // mostly faint, a few brighter or darker grains
{ // and a slow mottle (3 per cell) in the colour, so no two patches of sand are quite the same shade
const per = 3, lw = Wc * per + 2, lh = Hc * per + 2, lat = new Float32Array(lw * lh), k = res / per;
for (let i = 0; i < lat.length; i++) lat[i] = (R() - 0.5) * 0.07;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const fx = x / k, fy = y / k, ix = Math.floor(fx), iy = Math.floor(fy), ux = fx - ix, uy = fy - iy, tx = ux * ux * (3 - 2 * ux), ty = uy * uy * (3 - 2 * uy);
F.fine[y * w + x] += (lat[iy * lw + ix] * (1 - tx) + lat[iy * lw + ix + 1] * tx) * (1 - ty) + (lat[(iy + 1) * lw + ix] * (1 - tx) + lat[(iy + 1) * lw + ix + 1] * tx) * ty;
}
}
fieldUnraked(F);
return F;
}
function fieldUnraked(F) { // unraked gravel: low lumps plus grain (into the base and the live field)
const { w, h } = F, R = rng(F.seed * 31 + 977), G8 = F.res / 4, gw = Math.ceil(w / G8) + 2, gh = Math.ceil(h / G8) + 2, coarse = new Float32Array(gw * gh);
const lump = 0.26 + R() * 0.16; // how lumpy this garden's unraked gravel is
for (let i = 0; i < coarse.length; i++) coarse[i] = (R() - 0.5) * lump;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const fx = x / G8, fy = y / G8, ix = Math.floor(fx), iy = Math.floor(fy), tx = fx - ix, ty = fy - iy;
const a = coarse[iy * gw + ix], b = coarse[iy * gw + ix + 1], c = coarse[(iy + 1) * gw + ix], d = coarse[(iy + 1) * gw + ix + 1];
F.base[y * w + x] = (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty + F.grain[y * w + x];
}
F.hf.set(F.base);
}
function fieldLight(F, x0 = 0, y0 = 0, x1 = F.w, y1 = F.h) { // a low sun from the upper left lights the slopes
const w = F.w, h = F.h, hf = F.hf, data = F.img.data;
const base = hexRGB(color("--sand")), hi = hexRGB(color("--groove-hi")), sh = hexRGB(color("--groove"));
const L = [-0.55, -0.62, 0.56], ln = Math.hypot(...L); L[0] /= ln; L[1] /= ln; L[2] /= ln;
const dark = base[0] + base[1] + base[2] < 384;
const flat = L[2], k = 2.4 * F.sc, gain = 3.2, dim = dark ? 0.7 : 1, gr = dark ? 0.55 : 1; // slopes measured per 1/32 of a cell; dark sand is lit more softly, its grain fainter (it is gravel by night, not asphalt)
x0 = Math.max(0, x0 | 0); y0 = Math.max(0, y0 | 0); x1 = Math.min(w, Math.ceil(x1)); y1 = Math.min(h, Math.ceil(y1));
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const i = y * w + x;
const gx = (hf[y * w + Math.min(w - 1, x + 1)] - hf[y * w + Math.max(0, x - 1)]) * 0.5;
const gy = (hf[Math.min(h - 1, y + 1) * w + x] - hf[Math.max(0, y - 1) * w + x]) * 0.5;
const nx = -gx * k, ny = -gy * k, nn = Math.hypot(nx, ny, 1);
let t = ((nx * L[0] + ny * L[1] + L[2]) / nn - flat) * gain;
t = t > 1 ? 1 : t < -1 ? -1 : t;
const tgt = t >= 0 ? hi : sh, f = (t >= 0 ? t * 0.85 : -t * 0.8) * dim, a = 1 + (F.grain[i] * 0.3 + F.fine[i]) * gr;
const o = i * 4;
data[o] = (base[0] + (tgt[0] - base[0]) * f) * a; data[o + 1] = (base[1] + (tgt[1] - base[1]) * f) * a;
data[o + 2] = (base[2] + (tgt[2] - base[2]) * f) * a; data[o + 3] = 255;
}
if (x1 > x0 && y1 > y0) F.c2.putImageData(F.img, 0, 0, x0, y0, x1 - x0, y1 - y0);
}
function fieldFlush(F) { const d = F.dirty; if (d) { F.dirty = null; fieldLight(F, d[0] - 1, d[1] - 1, d[2] + 1, d[3] + 1); } }
/* The rake head, held perpendicular to its motion from (ax, ay) to (bx, by) (field samples), sets the sand's
cross-section: a groove under each tine, a ridge between. weight < 1 presses lightly; mask(x, y) keeps sand untouched. */
function fieldRake(F, ax, ay, bx, by, Wr, tines, weight = 1, mask = null) {
const dx = bx - ax, dy = by - ay, L = Math.hypot(dx, dy); if (L < 0.25) return false;
const tx = dx / L, ty = dy / L, nx = -ty, ny = tx;
const half = Wr / 2, sp = Wr / tines, m1 = 0.07 * F.res, pad = half + m1 + 1, w = F.w; // m1: the swath's soft outer margin
const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - pad)), x1 = Math.min(F.w, Math.ceil(Math.max(ax, bx) + pad));
const y0 = Math.max(0, Math.floor(Math.min(ay, by) - pad)), y1 = Math.min(F.h, Math.ceil(Math.max(ay, by) + pad));
if (x1 <= x0 || y1 <= y0) return true;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const qx = x + 0.5 - ax, qy = y + 0.5 - ay, b = qx * tx + qy * ty;
if (b < -0.7 || b > L + 0.7) continue;
const a = qx * nx + qy * ny, aa = Math.abs(a);
if (aa > half + m1 || (mask && mask(x + 0.5, y + 0.5))) continue;
// a groove under each tine, a ridge between; the outer berms are lower, and slope gently into the sand beside the swath
let prof = 0.5 * Math.cos(2 * Math.PI * (a + half) / sp);
if (aa > half) prof = 0.5 * 0.65 * (1 - (aa - half) / m1);
else if (aa > half - sp / 2 && prof > 0) prof *= 0.65;
const t = Math.min(1, (half + m1 - aa) / m1), wgt = weight * t * t * (3 - 2 * t), i = y * w + x;
F.hf[i] = F.hf[i] * (1 - wgt) + (prof + F.grain[i] * 0.25) * wgt;
}
const d = F.dirty; F.dirty = d ? [Math.min(d[0], x0), Math.min(d[1], y0), Math.max(d[2], x1), Math.max(d[3], y1)] : [x0, y0, x1, y1];
return true;
}
/* ---------- a garden's sand: the abbot's grooves, the footprints of the walk in, and the rake ---------- */
let PZ = null;
const RAKE_W = 0.9; // the rake spans 0.9 of a cell, so its 3 tines sit at 0.2, 0.5, 0.8
function cellSegment(r, c, e, x) { // the groove's centre line through cell (r, c), from side e to side x (e null: the rake is set down here)
const port = d => [c + 0.5 + DC[d] * 0.5, r + 0.5 + DR[d] * 0.5];
const p1 = port(x);
if (e === null) { const p0 = [c + 0.5 - DC[x] * 0.5, r + 0.5 - DR[x] * 0.5]; return { type: "line", p0, p1, len: 1 }; } // set down at the back edge: flush with the wall, stone or lane behind it
const p0 = port(e);
if (e === opp(x)) return { type: "line", p0, p1, len: 1 };
const C = [c + ((e === 1 || x === 1) ? 1 : 0), r + ((e === 2 || x === 2) ? 1 : 0)];
const a0 = Math.atan2(p0[1] - C[1], p0[0] - C[0]);
let da = Math.atan2(p1[1] - C[1], p1[0] - C[0]) - a0;
while (da > Math.PI) da -= 2 * Math.PI;
while (da < -Math.PI) da += 2 * Math.PI;
return { type: "arc", C, a0, da, len: Math.abs(da) * 0.5 };
}
function strokeOf(P) { // the raking stroke, in raking order (from where the rake is set down, out through the gate)
const n = P.length, segs = [];
for (let i = n - 1; i >= 0; i--) {
const [r, c] = rc(P[i]);
const x = i === 0 ? G.gateDir : dirTo(P[i], P[i - 1]);
const e = i < n - 1 ? dirTo(P[i], P[i + 1]) : (G.endCell >= 0 && P[i] === G.endCell && G.end_side ? DIX[G.end_side] : null);
const g = cellSegment(r, c, e, x); g.cell = P[i]; segs.push(g);
}
const [gr, gc] = rc(G.gateCell), d = G.gateDir, p0 = [gc + 0.5 + DC[d] * 0.5, gr + 0.5 + DR[d] * 0.5];
segs.push({ type: "line", p0, p1: [p0[0] + DC[d] * 0.6, p0[1] + DR[d] * 0.6], len: 0.6, cell: -1 });
let acc = 0; for (const g of segs) { g.at = acc; acc += g.len; }
return { segs, L: acc };
}
function strokePoint(st, s) { // position (in cells) and unit tangent at arc length s along the stroke
s = Math.max(0, Math.min(st.L, s));
let k = st.segs.length - 1; while (k > 0 && st.segs[k].at > s) k--;
const g = st.segs[k], u = g.len ? (s - g.at) / g.len : 0;
if (g.type === "line") {
const dx = g.p1[0] - g.p0[0], dy = g.p1[1] - g.p0[1], l = Math.hypot(dx, dy) || 1;
return { x: g.p0[0] + dx * u, y: g.p0[1] + dy * u, tx: dx / l, ty: dy / l, k };
}
const a = g.a0 + g.da * u, sg = Math.sign(g.da), rad = g.rad || 0.5;
return { x: g.C[0] + rad * Math.cos(a), y: g.C[1] + rad * Math.sin(a), tx: -Math.sin(a) * sg, ty: Math.cos(a) * sg, k };
}
function rakeAlong(F, st, s0, s1) { // draw the rake along the stroke from s0 to s1, in short chords; it is set down gently
const step = Math.max(0.6, 0.012 * F.res) / F.res;
let p = strokePoint(st, s0);
for (let s = s0 + step; ; s += step) {
const ss = Math.min(s, s1), q = strokePoint(st, ss);
fieldRake(F, p.x * F.res, p.y * F.res, q.x * F.res, q.y * F.res, RAKE_W * F.res, 3, Math.min(1, 0.4 + 0.6 * ss / 0.05));
p = q;
if (ss >= s1) break;
}
}
function footDirs() { // which way the footprints in each cell of the route point
const m = new Map();
path.forEach((k, i) => m.set(k, i + 1 < path.length ? dirTo(k, path[i + 1]) : (i ? dirTo(path[i - 1], k) : opp(G.gateDir))));
return m;
}
function pressFoot(F, k, d) { // two footprints per cell, as in the walk in (they stay inside the cell)
const [r, c] = rc(k), res = F.res, ax = DC[d], ay = DR[d], px = -ay, py = ax;
for (const side of [-1, 1]) {
const cx = (c + 0.5 + ax * side * 0.09 + px * side * 0.11) * res, cy = (r + 0.5 + ay * side * 0.09 + py * side * 0.11) * res;
const ru = 0.13 * res, rv = 0.07 * res, R = ru * 1.4 + 1;
for (let y = Math.max(0, Math.floor(cy - R)); y < Math.min(F.h, Math.ceil(cy + R)); y++)
for (let x = Math.max(0, Math.floor(cx - R)); x < Math.min(F.w, Math.ceil(cx + R)); x++) {
const qx = x + 0.5 - cx, qy = y + 0.5 - cy, u = (qx * ax + qy * ay) / ru, v = (qx * px + qy * py) / rv, r2 = u * u + v * v;
if (r2 < 1) F.hf[y * F.w + x] -= 0.42 * Math.sqrt(1 - r2);
else if (r2 < 1.9) F.hf[y * F.w + x] += 0.07 * (1 - (r2 - 1) / 0.9);
}
}
}
function pzBuild() { // a fresh garden: unraked sand, plus the grooves the abbot has already raked
PZ = makeField(G.W, G.H, fieldRes(G.W), freshSeed());
for (const [k, ds] of Object.entries(G.clues || {})) {
const [r, c] = k.split(",").map(Number), g = cellSegment(r, c, DIX[ds[0]], DIX[ds[1]]);
g.at = 0; rakeAlong(PZ, { segs: [g], L: g.len }, 0, g.len);
}
// finished rings: a stone whose eight neighbours are already raked, as one circle around it
for (let r = 1; r < G.H - 1; r++) for (let c = 1; c < G.W - 1; c++) {
if (G.rows[r][c] !== "#" || !RING8.every(([a, b]) => G.rows[r + a][c + b] === "R")) continue;
const ring = { type: "arc", C: [c + 0.5, r + 0.5], a0: Math.PI * 0.75, da: 2 * Math.PI, rad: 1.0, len: 2 * Math.PI, at: 0 };
rakeAlong(PZ, { segs: [ring], L: ring.len }, 0, ring.len);
}
PZ.dirty = null; PZ.base.set(PZ.hf);
pzWalk(true);
}
function pzWalk(full = false) { // the sand as the walk in leaves it; only cells whose footprints changed are redone
const want = footDirs(), F = PZ;
if (full || !F.feet) {
F.hf.set(F.base);
for (const [k, d] of want) pressFoot(F, k, d);
F.feet = want; fieldLight(F); return;
}
const changed = new Set();
for (const [k, d] of F.feet) if (want.get(k) !== d) changed.add(k);
for (const [k, d] of want) if (F.feet.get(k) !== d) changed.add(k);
for (const k of changed) {
const [r, c] = rc(k), x0 = c * F.res, y0 = r * F.res;
for (let y = y0; y < y0 + F.res; y++) F.hf.set(F.base.subarray(y * F.w + x0, y * F.w + x0 + F.res), y * F.w + x0);
if (want.has(k)) pressFoot(F, k, want.get(k));
fieldLight(F, x0 - 1, y0 - 1, x0 + F.res + 1, y0 + F.res + 1);
}
F.feet = want;
}
/* ---------- the practice garden: free raking on the same sand ---------- */
const PR = { W: 8, H: 6, res: 32, F: null, stones: [], tool: "rake", tines: 3, sm: null, head: null, down: false };
function prInit() {
PR.res = fieldRes(PR.W);
PR.F = makeField(PR.W, PR.H, PR.res, freshSeed());
PR.stones = [{ x: 2.1, y: 2.3, r: 0.5, s: 11 }, { x: 5.8, y: 3.9, r: 0.42, s: 23 }, { x: 6.5, y: 1.6, r: 0.3, s: 37 }];
fieldLight(PR.F);
}
function prSmooth() { fieldUnraked(PR.F); fieldLight(PR.F); }
function prInStone(x, y) {
for (const s of PR.stones) { const dx = x / PR.res - s.x, dy = (y / PR.res - s.y) / 0.86; if (dx * dx + dy * dy < (s.r * 0.97) ** 2) return true; }
return false;
}
function prStamp(ax, ay, bx, by) { return fieldRake(PR.F, ax, ay, bx, by, PR.res * (PR.tines === 3 ? 0.8 : 1.25), PR.tines, 1, prInStone); }
function prPoint(ev) { const [x, y] = pointerCell(ev); return [(x - geom.ox) / geom.cs * PR.res, (y - geom.oy) / geom.cs * PR.res]; }
function prDown(ev) {
const [x, y] = prPoint(ev);
if (PR.tool === "stone") {
const cx = x / PR.res, cy = y / PR.res;
const hit = PR.stones.findIndex(s => Math.hypot(cx - s.x, (cy - s.y) / 0.86) < s.r);
if (hit >= 0) { PR.stones.splice(hit, 1); setStatus("Stone lifted: the sand under it was never raked."); }
else if (cx > 0.45 && cx < PR.W - 0.45 && cy > 0.45 && cy < PR.H - 0.45 && PR.stones.every(s => Math.hypot(cx - s.x, cy - s.y) > s.r + 0.5)) {
PR.stones.push({ x: cx, y: cy, r: 0.42, s: (Math.random() * 1e6) | 0 }); setStatus("");
}
drawPractice(); return;
}
PR.down = true; PR.sm = [x, y]; PR.head = null; cv.setPointerCapture(ev.pointerId);
}
function prMove(ev) {
if (!PR.down) return;
const evs = ev.getCoalescedEvents ? ev.getCoalescedEvents() : [ev];
for (const e of (evs.length ? evs : [ev])) {
const [x, y] = prPoint(e);
const nx = PR.sm[0] + (x - PR.sm[0]) * 0.5, ny = PR.sm[1] + (y - PR.sm[1]) * 0.5; // light smoothing: a rake doesn't jitter
if (prStamp(PR.sm[0], PR.sm[1], nx, ny)) { PR.head = [nx, ny, nx - PR.sm[0], ny - PR.sm[1]]; PR.sm = [nx, ny]; }
}
fieldFlush(PR.F);
drawPractice();
}
function prUp() { PR.down = false; PR.head = null; drawPractice(); }
function drawPractice() {
const { ox, oy, cs } = geom;
frame();
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
ctx.drawImage(PR.F.cvs, ox, oy, cs * PR.W, cs * PR.H);
if (!sandTex) sandTex = makeSandTex();
ctx.save(); ctx.beginPath(); ctx.rect(ox, oy, cs * PR.W, cs * PR.H); ctx.clip(); ctx.globalAlpha = 0.4; ctx.drawImage(sandTex, 0, 0); ctx.restore();
for (const s of PR.stones) inkStone(ox + s.x * cs, oy + s.y * cs, s.r * cs, s.s);
if (PR.head) { // the rake head, with its tines
const [x, y, dx, dy] = PR.head, L = Math.hypot(dx, dy) || 1, nx = -dy / L, ny = dx / L, sc = cs / PR.res;
const half = PR.res * (PR.tines === 3 ? 0.8 : 1.25) / 2 * sc, X = ox + x * sc, Y = oy + y * sc;
ctx.save(); ctx.strokeStyle = color("--fg"); ctx.globalAlpha = 0.7; ctx.lineWidth = Math.max(2, cs / 18); ctx.lineCap = "round";
ctx.beginPath(); ctx.moveTo(X - nx * half, Y - ny * half); ctx.lineTo(X + nx * half, Y + ny * half); ctx.stroke();
ctx.restore();
}
}
function enterPoems() { // the poems earned so far, in the order of the gardens, with their Japanese and sources
cancelPoem();
view = "poems"; VIEW_EL.classList.remove("practice"); VIEW_EL.classList.add("poems"); stopAnim(); dragging = false; clearAdvice();
el("time").textContent = "Any time"; el("gname").textContent = "Poems"; el("seal").hidden = true;
const sc = el("scroll"); sc.innerHTML = "";
const earned = GARDENS.filter(g => !g.daily && (progress[g.name] || {}).done).length;
el("rule").hidden = false; el("rule").innerHTML = `[${earned ? `${earned} of ${NCAMP} poems earned` : "No poems yet"}: each garden you rake, at par or not, gives one.]<span class="facts">[The texts are classical waka and haiku; the English versions are working translations. Tap a garden's name to go back to it.]</span>`;
setStatus("");
const esc = escapeHtml;
{ const lead = document.createElement("p"); lead.className = "left"; lead.textContent = earned ? `[${earned} of ${NCAMP} poems earned, one for each garden raked.]` : "[Rake a garden, and its poem is kept here.]"; sc.appendChild(lead); }
const entry = (p, who, onName) => {
const a = document.createElement("article"); a.className = "entry";
a.innerHTML = `<div class="verse" lang="en">${esc(p.en)}</div><div class="by">${esc(p.poet)}<span class="g"> · </span></div>` +
`<div class="ja" lang="ja">${esc(p.ja)}</div><div class="src">${esc([p.dates, p.src].filter(Boolean).join(" · "))}${p.url ? `${p.dates || p.src ? " · " : ""}<a href="${esc(p.url)}" target="_blank" rel="noopener">source</a>` : ""}</div>`;
const g = a.querySelector(".g");
if (onName) { const b = document.createElement("button"); b.type = "button"; b.textContent = who; b.title = "Go back to this garden"; b.onclick = onName; g.appendChild(b); } else g.append(who);
return a;
};
for (let w = 1; w <= 7; w++) {
const items = GARDENS.map((g, i) => [g, i]).filter(([g]) => g.world === w && !g.daily), have = items.filter(([g]) => (progress[g.name] || {}).done);
if (!have.length) continue;
const h = document.createElement("h3"); h.textContent = TIMES[w]; sc.appendChild(h);
for (const [g, i] of have) sc.appendChild(entry(g.poem, g.name, () => { pickGarden(i); }));
if (have.length < items.length) { const p = document.createElement("p"); p.className = "left"; p.textContent = `[${items.length - have.length} more in the gardens of ${TIMES[w]}.]`; sc.appendChild(p); }
}
if (PRACTICE_POEM && PRACTICE_POEM.en) { const h = document.createElement("h3"); h.textContent = "Practice garden"; sc.appendChild(h); sc.appendChild(entry(PRACTICE_POEM, "Practice garden", () => { enterPractice(); showStage(); })); }
{ const h = document.createElement("h3"); h.textContent = "Sounds"; sc.appendChild(h); const p = document.createElement("p"); p.className = "left"; p.id = "soundCredits";
p.append("[The bamboo pipe and its drips, the wind, the birds by day and the crickets by night, the rake's scrape, the footsteps, the temple bell and the seal are all synthesized in the page; nothing of them is recorded. ");
if (!SND.tracks) p.append("No shakuhachi recording is carried in this copy of the page" + (document.querySelector("script.piece") ? ", or this browser cannot play its encoding.]" : ".]"));
else {
p.append("Shakuhachi, now and then: ");
SND.tracks.forEach((tr, i) => {
if (tr.source) { const a = document.createElement("a"); a.href = tr.source; a.target = "_blank"; a.rel = "noopener"; a.textContent = tr.title; p.appendChild(a); } else p.append(tr.title);
p.append(`${tr.player ? `, ${tr.player}` : ""}${tr.license ? ` (${tr.license})` : ""}${i < SND.tracks.length - 1 ? "; " : ".]"}`);
});
}
sc.appendChild(p); }
renderNav();
const r = sc.getBoundingClientRect(); if (r.top < 0 || r.top > window.innerHeight * 0.6) sc.scrollIntoView({ block: "start" });
}
function enterPractice() {
cancelPoem();
view = "practice"; VIEW_EL.classList.remove("poems"); VIEW_EL.classList.add("practice"); stopAnim(); dragging = false; clearAdvice();
el("time").textContent = "Any time"; el("gname").textContent = "Practice garden"; el("seal").hidden = true;
el("rule").hidden = false; el("rule").innerHTML = "[No goal and no score: rake as you like.]<span class=\"facts\">[The same sand as the gardens, raked freely: grooves are carved into a heightfield and lit by a low sun.]</span>";
setStatus("");
if (!PR.F) prInit();
cv.setAttribute("aria-label", "Practice garden: drag to rake freely.");
renderNav(); resize(); syncBeckon();
}
el("toolRake").onclick = () => { PR.tool = "rake"; el("toolRake").setAttribute("aria-pressed", "true"); el("toolStone").setAttribute("aria-pressed", "false"); };
el("toolStone").onclick = () => { PR.tool = "stone"; el("toolStone").setAttribute("aria-pressed", "true"); el("toolRake").setAttribute("aria-pressed", "false"); };
el("tines").onclick = () => { PR.tines = PR.tines === 3 ? 5 : 3; el("tines").textContent = `${PR.tines} tines`; };
el("smooth").onclick = () => { prSmooth(); drawPractice(); setStatus(""); };
(() => { // the practice garden's poem
const f = el("ppoem");
if (!PRACTICE_POEM) { f.remove(); return; }
f.textContent = PRACTICE_POEM.en;
const c = document.createElement("figcaption"); c.textContent = PRACTICE_POEM.poet; f.appendChild(c);
})();
/* ---------- start-up ---------- */
window.addEventListener("resize", () => {
if (view === "puzzle" && G && PZ && Math.abs(fieldRes(G.W) - PZ.res) / PZ.res > 0.3) {
if (mode === "reveal") finishAnimNow();
pzBuild();
if (mode === "done") { const st = strokeOf(path); rakeAlong(PZ, st, 0, st.L); PZ.dirty = null; fieldLight(PZ); }
}
if (G || view === "practice") resize();
});
const retheme = () => { PAL = null; sandTex = null; if (PR.F) fieldLight(PR.F); if (PZ) fieldLight(PZ); draw(); };
if (window.matchMedia) matchMedia("(prefers-color-scheme: dark)").addEventListener?.("change", retheme);
new MutationObserver(retheme).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
syncAssists();
function lastGarden() { // the garden last open, if its walk is unfinished (else the first unraked one)
try {
const i = parseInt(localStorage.getItem("samon-last")), g = GARDENS[i];
if (g && !g.daily && worldOpen(g.world) && Array.isArray((progress[g.name] || {}).draft) && progress[g.name].draft.length > 1) return i;
} catch (e) {}
return firstUnraked();
}
function start(data) {
ensureDaily(); renderMute();
if (applyHash()) return; // a link to a raking, or carrying progress
const i = (data && Number.isInteger(data.idx) && data.idx < GARDENS.length) ? data.idx : lastGarden();
load(i);
if (!(data && Array.isArray(data.path) && data.path.length)) maybeDemo();
if (data && Array.isArray(data.path) && data.path.length) { path = data.path; routeChanged("cut"); }
if (data && data.view === "practice") enterPractice();
if (data && data.view === "poems") enterPoems();
}
window.claude?.hot?.snapshot?.(() => ({ idx, view, path: mode === "draw" ? path : [] }));
window.claude?.hot?.ready ? window.claude.hot.ready(start) : start(window.claude?.hot?.data ?? {});
/* test hooks */
window.__samon = {
get path() { return path; }, get mode() { return mode; }, get G() { return G; }, get idx() { return idx; }, get view() { return view; }, get ghosts() { return ghosts; },
geom: () => geom, gate: () => gatePoint(), worldOpen, startDemo, get demoRunning() { return !!demoRun || mode === "demo" || (mode === "reveal" && !!REV && !!REV.demo); }, get obs() { return obs; }, set budget(v) { abbotBudget = v; }, set poemTiming(v) { Object.assign(POEM_T, v); },
get poem() { const b = el("poem"); return { state: poemRun ? (poemRun.stay ? "stay" : poemRun.shown ? "shown" : "waiting") : "none", visible: !b.hidden && b.classList.contains("on"),
verse: el("verse").textContent, ja: el("ja").hidden ? "" : el("ja").textContent, leaving: el("stage").classList.contains("leaving"), clash: !b.hidden && sealClash() }; },
poemFit(i, orig) { // lay garden i's poem over it as if raked at par (seal shown), with or without the original: does it fit?
load(i); const s = el("seal"), box = el("poem"); s.hidden = false; box.hidden = false; fillPoem(GARDENS[i].poem, box);
if (orig) { el("ja").hidden = false; el("src").hidden = false; el("orig").hidden = true; fitPoem(box); }
const res = { clash: sealClash(), under: s.classList.contains("under"), left: box.firstElementChild.style.justifySelf === "start",
over: box.firstElementChild.scrollHeight > box.clientHeight * 0.92, fs: parseFloat(box.style.getPropertyValue("--poem-fs")) };
hidePoem(); s.hidden = true; return res; },
poems: () => ({ gardens: GARDENS.filter(g => !g.daily && g.poem && g.poem.en && g.poem.ja && g.poem.poet).length, practice: !!(PRACTICE_POEM && PRACTICE_POEM.en) }), get helped() { return helped; },
set today(v) { todayOverride = v; }, pickDaily, enterPoems,
sound: { get on() { return SND.on; }, get ready() { return !!SND.ctx; }, get state() { return SND.ctx ? SND.ctx.state : "none"; }, get tracks() { return SND.tracks; }, get playing() { return SND.cur ? SND.cur.tr.file : null; }, get timers() { return SND.timers.size; },
set: setSound, drip, tock, tok, bell, piece: maybePiece, play: (i, from) => playPiece(SND.tracks[i], from), render: renderSound, crunch, birds, crickets, gust, dayPart,
get scraping() { return !!SND.scr; }, get vol() { return SND.vol; }, get haptics() { return SND.haptics; }, setVolume, setHaptics, get master() { return SND.master ? SND.master.gain.value : -1; },
get blocked() { return soundBlocked(); }, renderMute,
level() { if (!SND.meter) return -1; const a = new Float32Array(SND.meter.fftSize); SND.meter.getFloatTimeDomainData(a); return Math.sqrt(a.reduce((s, v) => s + v * v, 0) / a.length); } }, get daily() { const i = ensureDaily(); if (i < 0) return null; const g = GARDENS[i], p = progress[g.name] || {}; return { idx: i, key: g.dateKey, dayNo: g.dayNo, year: DAILY.length, name: g.name, done: !!p.done, turns: p.turns, kind: p.kind, streak: dailyStreak(), rakeShown: !el("rakeDay").hidden }; },
strokeBends(cells) { const P = cells.map(([r, c]) => key(r, c)); return [strokeOf(P).segs.filter(s => s.type === "arc").length, turnsOf(P)]; }, stepTo, load, key: (r, c) => key(r, c), turnsOf, analyze, rakeNow, askAbbot, enterPractice,
setPath(cells) { if (view !== "puzzle") return; cancelPoem(); mode = "draw"; stopAnim(); el("seal").hidden = true; el("next").hidden = true; if (PZ) PZ.feet = null; path = cells.map(([r, c]) => key(r, c)); routeChanged("cut"); },
analyzeFor(i, cells) { if (idx !== i || view !== "puzzle") load(i); return analyze(cells.map(([r, c]) => key(r, c))); },
randomWalk(i, seed, maxLen) { // a random legal route (by the page's own rules), for testing the abbot's search
if (idx !== i || view !== "puzzle") load(i);
const R = rng(seed), S = newState(); push(S, G.gateCell);
while (S.len < maxLen) {
const v = S.P[S.len - 1], opts = [];
for (let d = 0; d < 4; d++) { const w = G.nbr[v * 4 + d]; if (w >= 0 && check(S, w) === null) opts.push(w); }
if (!opts.length) break;
push(S, opts[Math.floor(R() * opts.length)]);
}
return Array.from(S.P.slice(0, S.len)).map(k => rc(k));
},
practice: () => ({ stones: PR.stones.length, sum: PR.F ? PR.F.hf.reduce((a, b) => a + Math.abs(b), 0) : 0 }),
sand: () => PZ ? PZ.hf.reduce((a, b) => a + Math.abs(b), 0) : 0,
share: { code: (cells, grey) => rakingCode(cells ? cells.map(([r, c]) => key(r, c)) : path, grey), progress: progressCode, read: readRaking, readProgress, importProgress,
grid: cells => gridText(cells ? cells.map(([r, c]) => key(r, c)) : path), text: (cells, kind) => shareText(GARDENS[idx], cells ? cells.map(([r, c]) => key(r, c)) : path, kind),
get shared() { return shared; }, walk: cells => { const n = walkEncode(cells.map(([r, c]) => key(r, c))); return n === null ? null : [n.toString(), bitLen(n)]; },
unwalk: (n, steps) => { const P = walkDecode(BigInt(n), steps); return P && P.map(k => rc(k)); }, applyHash, leaveShared,
layouts: () => ({ campaign: GARDENS.slice(0, NCAMP).map(layoutHash), daily: DAILY.map(layoutHash) }),
swap(i, j) { [GARDENS[i], GARDENS[j]] = [GARDENS[j], GARDENS[i]]; LAYOUTS = null; renderNav(); } },
};
</script>
proto/test/crisp.py — Zoomed crops of finished rakings (device pixels, 1:1) and frame timing during the reveal.
"""Zoomed crops of finished rakings (device pixels, 1:1) and frame timing during the reveal."""
import asyncio, json, sys
from playwright.async_api import async_playwright
G = json.load(open('/home/claude/zen/out/proto_gardens.json'))
URL = 'file:///home/claude/zen/proto/test/index.html'
async def run(b, name, w, dpr, tag):
page = await (await b.new_context(viewport={'width': w, 'height': 1000}, device_scale_factor=dpr)).new_page()
await page.goto(URL); await page.wait_for_timeout(300)
i = next(k for k, g in enumerate(G) if g['name'] == name)
await page.evaluate(f"window.__samon.load({i})")
res = await page.evaluate("window.__samon.G && document.getElementById('garden').width")
await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended'])
# frame timing during the reveal
times = await page.evaluate("""() => new Promise(done => { window.__samon.rakeNow(); const ts = []; let last = performance.now();
const f = now => { ts.push(now - last); last = now; if (ts.length < 120) requestAnimationFrame(f); else done(ts); }; requestAnimationFrame(f); })""")
await page.wait_for_timeout(7000)
el = await page.query_selector('#garden')
await el.screenshot(path=f'/home/claude/zen/proto/test/crisp-{tag}.png')
ts = sorted(times[1:])
print(tag, name, 'canvas px', res, f'frame ms median {ts[len(ts)//2]:.1f} p95 {ts[int(len(ts)*0.95)]:.1f} max {ts[-1]:.1f}')
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
await run(b, 'Spiral', 1200, 2, 'desktop2x')
await run(b, 'Calm', 390, 3, 'phone3x')
await b.close()
asyncio.run(main())
proto/test/frames.py — Capture frames of the raking reveal (and the walk-in sand) for a visual check.
"""Capture frames of the raking reveal (and the walk-in sand) for a visual check."""
import asyncio, json, sys
from playwright.async_api import async_playwright
G = json.load(open('/home/claude/zen/out/proto_gardens.json'))
URL = 'file:///home/claude/zen/proto/test/index.html'
NAME = sys.argv[1] if len(sys.argv) > 1 else 'Calm'
SCHEME = sys.argv[2] if len(sys.argv) > 2 else 'light'
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
page = await (await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme=SCHEME)).new_page()
await page.goto(URL); await page.wait_for_timeout(300)
i = next(k for k, g in enumerate(G) if g['name'] == NAME)
await page.evaluate(f"window.__samon.load({i})")
cells = G[i]['intended']
await page.evaluate("(w) => window.__samon.setPath(w)", cells[:len(cells) * 2 // 3])
el = await page.query_selector('#garden')
await el.screenshot(path=f'/home/claude/zen/proto/test/frame-{NAME}-{SCHEME}-walk.png')
await page.evaluate("(w) => window.__samon.setPath(w)", cells)
await page.evaluate("window.__samon.rakeNow()")
t = 0
for k, ms in enumerate([700, 1300, 1600]):
await page.wait_for_timeout(ms); t += ms
await el.screenshot(path=f'/home/claude/zen/proto/test/frame-{NAME}-{SCHEME}-{k}.png')
await page.wait_for_timeout(5000)
await el.screenshot(path=f'/home/claude/zen/proto/test/frame-{NAME}-{SCHEME}-done.png')
print(await page.evaluate("[window.__samon.mode, document.getElementById('status').textContent]"))
await b.close()
asyncio.run(main())
proto/test/fuzz.py — Gesture fuzzer for the prototype: random drags, taps, keys, button presses and links (shared rakings, carried progress) for a while, against invariants.
"""Gesture fuzzer for the prototype: random drags, taps, keys, button presses and links (shared rakings, carried progress) for a while, against invariants.
Invariants, after every action: the walk is a legal chain of sand cells from the gate with no repeats; the mode, view and
the controls agree (Next only when done, Rake only in a finishable daily walk, Restore only with something to restore,
Step back and Start over disabled when the daily raking is final); the status is empty or in editorial brackets; the
saved progress parses and, after a reload, brings the garden and its draft back; the sound engine keeps a bounded number of
timers; no page errors, no console errors.
Usage: python3 proto/test/fuzz.py [seconds=180] [seed=1]
"""
import asyncio, json, random, sys, time
from playwright.async_api import async_playwright
URL = 'file:///home/claude/zen/proto/test/index.html'
SECS = float(sys.argv[1]) if len(sys.argv) > 1 else 180
SEED = int(sys.argv[2]) if len(sys.argv) > 2 else 1
R = random.Random(SEED)
STATE_JS = """() => {
const s = window.__samon, G = s.G, el = id => document.getElementById(id);
const vis = id => { const e = el(id); return !!e && !e.hidden && getComputedStyle(e).display !== 'none'; };
return { mode: s.mode, view: s.view, path: s.path.slice(), W: G.W, H: G.H, rows: G.rows, gate: G.gateCell, nSand: G.nSand, daily: !!G.daily,
status: el('status').textContent, next: vis('next'), rakeDay: vis('rakeDay'), restore: vis('restore'), undoOff: el('undo').disabled, restartOff: el('restart').disabled,
timers: s.sound.timers, soundOn: s.sound.on, actions: [...document.querySelectorAll('#actions button')].map(b => b.textContent),
poem: s.poem.state, demo: s.demoRunning, idx: window.__samon.G.name, shared: !!s.share.shared, hash: location.hash };
}"""
def legal(st):
P, W, rows = st['path'], st['W'], st['rows']
if not P:
return True, ''
if P[0] != st['gate']:
return False, f'walk starts at {P[0]}, not the gate {st["gate"]}'
if len(set(P)) != len(P):
return False, 'walk repeats a cell'
for i, k in enumerate(P):
r, c = divmod(k, W)
if rows[r][c] in '#OR':
return False, f'walk stands on {rows[r][c]!r} at {(r, c)}'
if i and abs(k // W - P[i - 1] // W) + abs(k % W - P[i - 1] % W) != 1:
return False, f'walk jumps from {P[i - 1]} to {k}'
return True, ''
def consistent(st):
if st['mode'] not in ('draw', 'reveal', 'done', 'demo'):
return f'mode {st["mode"]!r}'
if st['view'] not in ('puzzle', 'practice', 'poems'):
return f'view {st["view"]!r}'
if st['view'] == 'puzzle':
if st['status'] and not (st['status'].startswith('[') and st['status'].endswith(']')):
return f'status not bracketed: {st["status"][:60]!r}'
if st['next'] and st['mode'] != 'done':
return 'Next shown outside done'
if st['rakeDay'] and not (st['daily'] and st['mode'] == 'draw' and len(st['path']) == st['nSand']):
return 'Rake shown when the daily walk is not complete'
if st['mode'] == 'done' and len(st['path']) != st['nSand']:
return f'done with {len(st["path"])} of {st["nSand"]} cells'
if st['daily'] and st['mode'] == 'done' and not st['shared'] and not (st['undoOff'] and st['restartOff']):
return 'daily raking final but Step back / Start over enabled'
if st['shared'] and st['mode'] != 'done':
return f'a shared raking on show but mode {st["mode"]!r}'
if st['hash'] and not st['shared'] and not st['hash'].startswith('#p='):
return f'a hash left over: {st["hash"][:30]!r}'
if st['timers'] > 60:
return f'{st["timers"]} sound timers'
return ''
async def main():
fails, log = [], []
async with async_playwright() as p:
b = await p.chromium.launch()
ctx = await b.new_context(viewport={'width': 420, 'height': 900}, device_scale_factor=1)
page = await ctx.new_page()
errors = []
page.on('pageerror', lambda e: errors.append('pageerror: ' + str(e)))
page.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' and 'Failed to load resource' not in m.text else None)
await page.goto(URL); await page.wait_for_timeout(300)
await page.evaluate("localStorage.setItem('samon-open-all', '1'); localStorage.setItem('samon-started', '1'); localStorage.setItem('samon-sound-hint', '1')")
await page.reload(); await page.wait_for_timeout(300)
await page.evaluate("window.__samon.poemTiming = {delay: 150, hold: 700, fade: 120}")
ngardens = await page.evaluate("window.__samon.share.layouts().campaign.length")
async def rect():
return await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()")
async def check(what):
st = await page.evaluate(STATE_JS)
ok, why = legal(st)
bad = ('' if ok else why) or consistent(st)
if bad:
fails.append((what, bad, st['idx'], st['mode'], st['view']))
print('FAIL', what, '->', bad, '| garden', st['idx'], st['mode'], st['view'])
print(' last actions:', ' | '.join(log[-12:]))
if errors:
fails.append((what, errors[:]))
print('FAIL', what, '-> errors', errors[:3]); errors.clear()
return st
t0 = time.time(); n = 0
while time.time() - t0 < SECS:
n += 1
kind = R.choices(['drag', 'tap', 'key', 'button', 'pip', 'pill', 'reload', 'wait', 'link'], weights=[34, 14, 16, 14, 7, 5, 3, 7, 4])[0]
try:
if kind == 'drag':
L, T, W, H = await rect()
x, y = L + R.uniform(-0.1, 1.1) * W, T + R.uniform(-0.1, 1.1) * H
await page.mouse.move(x, y); await page.mouse.down()
vx, vy = R.uniform(-60, 60), R.uniform(-60, 60)
for _ in range(R.randint(1, 14)):
if R.random() < 0.3: vx, vy = R.uniform(-80, 80), R.uniform(-80, 80)
x = min(max(x + vx + R.uniform(-8, 8), L - 60), L + W + 60); y = min(max(y + vy + R.uniform(-8, 8), T - 60), T + H + 60)
await page.mouse.move(x, y, steps=R.randint(1, 4))
await page.mouse.up()
log.append(f'drag@{x - L:.0f},{y - T:.0f}')
elif kind == 'tap':
L, T, W, H = await rect()
x, y = L + R.uniform(-0.05, 1.05) * W, T + R.uniform(-0.05, 1.05) * H
await page.mouse.click(x, y); log.append(f'tap@{x - L:.0f},{y - T:.0f}')
elif kind == 'key':
k = R.choice(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowRight', 'Backspace', 'r', 'h', 'Enter', 'Escape', 'm'])
await page.focus('#garden'); await page.keyboard.press(k); log.append('key ' + k)
elif kind == 'button':
SEL = '.veranda button, #actions button, .assists button, #mute, #soundMore, #soundPanel button, #nav .fold, .help summary, #orig, .poem .card'
ids = await page.evaluate(f"[...document.querySelectorAll('{SEL}')].filter(e => !e.hidden && !e.disabled && e.offsetParent !== null).map((e, i) => e.id || (e.className + '#' + i))")
if ids:
pick = R.randrange(len(ids)); which = ids[pick]
els = await page.query_selector_all(SEL)
vis = [e for e in els if await e.is_visible() and await e.is_enabled()]
if vis:
e = vis[min(pick, len(vis) - 1)]
await e.click(timeout=2000); log.append('button ' + which)
elif kind == 'pip':
pips = await page.query_selector_all('#nav .pip:not(:disabled)')
if pips:
e = R.choice(pips); await e.scroll_into_view_if_needed(); await e.click(timeout=2000); log.append('pip')
elif kind == 'pill':
pills = await page.query_selector_all('#nav .pill')
if pills:
e = R.choice(pills); await e.scroll_into_view_if_needed(); await e.click(timeout=2000); log.append('pill ' + (await e.text_content() or '')[:14])
if R.random() < 0.5:
await page.evaluate("window.__samon.G && window.__samon.load(window.__samon.G.daily ? 0 : Math.floor(Math.random() * 20))")
elif kind == 'reload':
before = await page.evaluate(STATE_JS)
await page.reload(); await page.wait_for_timeout(400)
await page.evaluate("window.__samon.poemTiming = {delay: 150, hold: 700, fade: 120}")
after = await page.evaluate(STATE_JS)
if before['view'] == 'puzzle' and before['mode'] == 'draw' and len(before['path']) > 1 and after['view'] == 'puzzle' and after['idx'] == before['idx'] and after['path'] != before['path'] and not after['demo']: # (a walk of one footprint, the gate, is not kept: it is no walk)
fails.append(('reload', f'draft lost: {len(before["path"])} -> {len(after["path"])}', before['idx']))
print('FAIL reload -> draft lost', before['idx'], len(before['path']), len(after['path']))
log.append('reload')
elif kind == 'link': # a shared raking, today's garden, or carried progress, by its hash
what = R.choice(['raking', 'raking', 'daily', 'progress', 'bad'])
if what == 'raking':
gi = R.randrange(ngardens)
code = await page.evaluate(f"(() => {{ const s = window.__samon, here = s.G.name; s.load({gi}); const c = s.share.code(s.G.intended); return c; }})()")
await page.evaluate(f"location.hash = '#r={code}'"); await page.wait_for_timeout(300); log.append(f'link raking {gi}')
elif what == 'daily':
await page.evaluate("location.hash = '#daily'"); await page.wait_for_timeout(300); log.append('link daily')
elif what == 'progress':
code = await page.evaluate("window.__samon.share.progress()")
await page.evaluate(f"location.hash = '#p={code}'"); await page.wait_for_timeout(300); log.append(f'link progress ({len(code)})')
if R.random() < 0.7:
btn = await page.query_selector('#actions button:has-text("Take it over")') if R.random() < 0.5 else await page.query_selector('#actions button:has-text("Leave it")')
if btn: await btn.click(timeout=2000); log.append('link answered')
else:
await page.evaluate("location.hash = '#r=' + Math.random().toString(36).slice(2)"); await page.wait_for_timeout(300); log.append('link bad')
else:
await page.wait_for_timeout(R.randint(100, 900)); log.append('wait')
except Exception as e: # a click that could not land (an element gone mid-way) is not a page failure
log.append(f'({kind}: {type(e).__name__})')
await check(f'#{n} {kind}')
if len(log) > 60: del log[:-60]
prog = await page.evaluate("(() => { try { return JSON.stringify(JSON.parse(localStorage.getItem('samon-progress') || '{}')).length; } catch (e) { return -1; } })()")
if prog < 0:
fails.append(('progress', 'does not parse'))
print(f'{n} actions in {time.time() - t0:.0f} s, progress {prog} chars, gardens {ngardens}; fails: {len(fails)}')
await b.close()
sys.exit(1 if fails else 0)
asyncio.run(main())
proto/test/grey_shot.py
import asyncio, json
from playwright.async_api import async_playwright
G = json.load(open('/home/claude/zen/out/proto_gardens.json'))
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
page = await (await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2)).new_page()
await page.goto('file:///home/claude/zen/proto/test/index.html'); await page.wait_for_timeout(300)
i = next(k for k, g in enumerate(G) if g['name'] == 'Spiral')
await page.evaluate(f"window.__samon.load({i})")
await page.click('#hint'); await page.click('#hint')
await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended'])
await page.evaluate("window.__samon.rakeNow()"); await page.wait_for_timeout(7000)
await page.screenshot(path='/home/claude/zen/proto/test/shot-grey.png', clip={'x': 0, 'y': 0, 'width': 390, 'height': 760})
await b.close()
asyncio.run(main())
proto/test/gt_check.py — Check the page's own hint analysis against the C solver on random legal routes.
"""Check the page's own hint analysis against the C solver on random legal routes.
For each route: can it still reach par? can it still be finished? if not, which is the nearest footprint from which
it can? (and is the par rewind point exact?) Ground truth: the C solver, with the route fixed by exact groove clues."""
import asyncio, json, random, sys, time
sys.path.insert(0, '/home/claude/zen/src')
from zg import Garden
from playwright.async_api import async_playwright
URL = 'file:///home/claude/zen/proto/test/index.html'
P = json.load(open('/home/claude/zen/out/proto_gardens.json'))
DIRS = {(-1, 0): 'N', (0, 1): 'E', (1, 0): 'S', (0, -1): 'W'}
N_WALKS = int(sys.argv[1]) if len(sys.argv) > 1 else 40
def garden_of(p, extra=None):
clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()}
clues.update(extra or {})
marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()}
return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None,
nohairpin=2 if p['wide'] else 0, braced_start=p['wide'], end_side=p['end_side'])
def d(a, b): return DIRS[(b[0] - a[0], b[1] - a[1])]
def prefix_clues(p, cells):
out = {}
for i in range(len(cells) - 1):
ds = {p['gate'][2] if i == 0 else d(cells[i], cells[i - 1]), d(cells[i], cells[i + 1])}
out[cells[i]] = ''.join(sorted(ds))
return out
cache = {}
def truth(p, cells, kind):
k = (p['name'], tuple(cells), kind)
if k not in cache:
g = garden_of(p, prefix_clues(p, cells))
r = g.solve(mode=0, cap=1) if kind == 'comp' else g.solve(mode=2, bound=p['par'], cap=1)
assert r['complete'] or r['capped'], 'solver incomplete'
cache[k] = r['nsol'] >= 1
return cache[k]
async def main():
# the flags the page assumes reproduce the exported counts
for p in P:
r = garden_of(p).solve(mode=0, cap=10**7)
assert r['nsol'] == p['rakings'], (p['name'], r['nsol'], p['rakings'])
async with async_playwright() as pw:
b = await pw.chromium.launch()
page = await (await b.new_context(viewport={'width': 390, 'height': 900})).new_page()
await page.goto(URL); await page.wait_for_timeout(300)
stats = {'par': 0, 'finish': 0, 'stuck': 0, 'unknown': 0}; bad = 0; tmax = 0; proven_back = 0; unproven_back = 0
unknown_in, unproven_in, slowest = [], [], ''
rng = random.Random(1)
for i, p in enumerate(P):
nsand = sum(ch in '.hv=|' for row in p['rows'] for ch in row)
sols = garden_of(p).solve(mode=0, cap=3000, stream=True)['sols'] # prefixes of real rakings: never stuck
for w in range(2 * N_WALKS):
if w % 2 == 0:
maxlen = rng.randint(2, nsand)
cells = await page.evaluate(f"window.__samon.randomWalk({i}, {rng.randrange(10**9)}, {maxlen})")
cells = [tuple(c) for c in cells]
else:
s_ = rng.choice(sols)['draw']
cells = [tuple(c) for c in s_[:rng.randint(1, len(s_))]]
t0 = time.time()
a = await page.evaluate("""([i, cells]) => { const t = performance.now(); const a = window.__samon.analyzeFor(i, cells);
return {state: a.state, lcp: a.lcp, back: a.back ?? null, proven: a.proven ?? null, ms: performance.now() - t}; }""", [i, cells])
if a['ms'] > tmax: tmax, slowest = a['ms'], p['name']
stats[a['state']] += 1
if a['state'] == 'unknown': unknown_in.append(p['name'])
n = len(cells)
gpar, gcomp = truth(p, cells, 'par'), truth(p, cells, 'comp')
ok = True
# the par rewind point is exact: cells[:lcp] can reach par, cells[:lcp+1] cannot (when lcp < n)
ok &= truth(p, cells[:a['lcp']], 'par') and (a['lcp'] == n or not truth(p, cells[:a['lcp'] + 1], 'par'))
if a['state'] == 'par': ok &= gpar
else: ok &= not gpar
if a['state'] == 'finish': ok &= gcomp
if a['state'] == 'stuck':
ok &= not gcomp
gback = next(k for k in range(n - 1, 0, -1) if truth(p, cells[:k], 'comp'))
if a['proven']: ok &= a['back'] == gback; proven_back += 1
else: ok &= truth(p, cells[:a['back']], 'comp') and a['back'] <= gback; unproven_back += 1; unproven_in.append(p['name'])
if not ok:
bad += 1; print('BAD', p['name'], cells, a, 'truth par/comp', gpar, gcomp)
print('states', stats, 'bad', bad, f'max analysis time {tmax:.0f} ms ({slowest})', 'stuck rewinds proven/unproven', proven_back, unproven_back)
print('unknown in', unknown_in, 'unproven rewinds in', sorted(set(unproven_in)))
json.dump({'routes': sum(stats.values()), 'states': stats, 'bad': bad, 'max_ms': round(tmax), 'proven_rewinds': proven_back,
'unproven_rewinds': unproven_back, 'slowest': slowest, 'unknown_in': unknown_in, 'unproven_in': unproven_in}, open('/home/claude/zen/out/hint_check.json', 'w'), indent=1)
await b.close()
asyncio.run(main())
proto/test/run.py — Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals;
"""Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals;
Restore after a rewind and after Start over; the abbot's hints (first hint, sketched groove, 'no longer at par' with its
rewind, 'stuck' with its rewind); the grey seal; unlocking; assists; the poems and the scroll of poems earned; the daily
garden; drag intent (drift, the grip beyond the walls, arcs, reversals, wobble); sound; sharing (a raking as a code in the URL's hash,
progress carried between devices, the share text); the practice garden; screenshots in light and dark."""
import asyncio, json, math, os, sys
from playwright.async_api import async_playwright
sys.path.insert(0, '/home/claude/zen/src')
from zg import Garden, check_solution, turns_of
G = json.load(open('/home/claude/zen/out/proto_gardens.json'))
URL = 'file:///home/claude/zen/proto/test/index.html'
SHOT = '/home/claude/zen/proto/test/'
fails = []
def garden_of(p, braced=True):
marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()}
clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()}
return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None,
nohairpin=2 if p['wide'] else 0, braced_start=p['wide'] and braced, end_side=p['end_side'])
def expect(cond, what):
print(('ok ' if cond else 'FAIL ') + what)
if not cond: fails.append(what)
async def centers(page, cells):
return await page.evaluate("""(path) => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect();
const s = r.width / cv.width; const g = window.__samon.geom();
return path.map(([rr, cc]) => [r.left + (g.ox + cc * g.cs + g.cs / 2) * s, r.top + (g.oy + rr * g.cs + g.cs / 2) * s]); }""", cells)
async def drag(page, cells):
pts = await centers(page, cells)
await page.mouse.move(*pts[0]); await page.mouse.down()
for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3)
await page.mouse.up()
async def quiet(page, **t): # poems off (they would carry the page on to the next garden), unless a test wants them
await page.evaluate("(t) => { window.__samon.poemTiming = t; }", t or {'delay': 3600000})
async def state(page):
return await page.evaluate("() => ({mode: window.__samon.mode, len: window.__samon.path.length, turns: window.__samon.turnsOf(), "
"status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''), "
"restore: !document.getElementById('restore').hidden, seal: getComputedStyle(document.getElementById('seal')).display, "
"ghosts: window.__samon.ghosts.size, actions: [...document.querySelectorAll('#actions button')].map(b => b.textContent)})")
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
errors = []
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page()
page.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(300)
await quiet(page)
st = await state(page)
expect(st['seal'] == 'none' and not st['restore'] and not await page.query_selector('#rake'), 'seal and Restore hidden at load; no Rake button')
# 0. a first visit: the first garden is raked once by the monk (walking in, then raking out) and reset for the player;
# then the monk waits in the gateway, a ring beckons, and a ghostly finger shows the drag
onboard = """() => ({beckon: !document.getElementById('beckon').hidden, finger: !document.getElementById('finger').hidden,
anim: document.getElementById('finger').getAnimations().length, status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''),
started: localStorage.getItem('samon-started')})"""
g0 = G[0]
expect(g0['demo'] and G[1]['demo'] and not G[2]['demo'], 'the first two gardens have demonstrations, the first real garden (third) none')
await page.wait_for_function("window.__samon.mode === 'demo'", timeout=4000)
st = await state(page); ob = await page.evaluate(onboard)
expect(st['status'].startswith('Watch') and not ob['beckon'], f"the first garden demonstrates itself, and nothing beckons meanwhile ({st['status']!r})")
await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=8000)
walked = await page.evaluate("window.__samon.path.slice()")
want = await page.evaluate("(cells) => cells.map(([r, c]) => window.__samon.key(r, c))", g0['intended'])
expect(walked == want, 'the demonstration walks the intended raking, then rakes it')
await page.wait_for_function("!window.__samon.demoRunning", timeout=20000)
st = await state(page); ob = await page.evaluate(onboard)
pill0 = await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(g0['name'])}).className")
expect(st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'] and st['seal'] == 'none' and 'done' not in pill0 and 'sealed' not in pill0,
f"then the garden is reset for the player, and the demonstration counts for nothing ({st['status']!r}, {pill0!r})")
expect(ob['beckon'] and ob['finger'] and ob['anim'] == 1, f'then the waiting monk beckons, and a finger shows the drag {ob}')
br = await page.evaluate("[document.getElementById('status').textContent, document.querySelector('.help.puzzle-only').textContent.trim(), document.querySelector('#nav .lock .note').textContent, document.getElementById('rule').firstChild.textContent, document.querySelector('#nav .lock .n').textContent, [...document.querySelectorAll('#actions button, .veranda button')].map(b => b.textContent), document.getElementById('par').parentElement.title]")
expect(all(t.startswith('[') and t.endswith(']') for t in br[:3]) and br[3].startswith('[') and not br[4].startswith('[') and not any(t.startswith('[') for t in br[5]) and 'valid raking' in br[6] and f"Par {g0['par']}" in br[6],
f"what the game says to the player is set in editorial brackets (status, help, lock notes, a garden's instructions), and rules, counts and controls are not; the counts of rakings live in Par's tooltip ({br[0][:40]!r}, {br[3][:30]!r}, {br[4][:20]!r})")
await page.reload(); await page.wait_for_timeout(1500)
await quiet(page)
st = await state(page); ob = await page.evaluate(onboard)
expect(not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and 'Drag the monk in' in st['status'] and ob['beckon'] and ob['finger'],
f"a demonstration seen once does not play again by itself ({st['status']!r})")
wv = await page.evaluate("!document.getElementById('watch').hidden")
await page.click('#watch'); await page.wait_for_timeout(700)
running = await page.evaluate("window.__samon.demoRunning")
mid = await centers(page, [g0['intended'][1]]); await page.mouse.click(*mid[0]); await page.wait_for_timeout(100)
st = await state(page)
expect(wv and running and not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'],
'Watch replays the demonstration, and a tap skips it')
await page.evaluate("window.__samon.load(2)")
expect(await page.evaluate("document.getElementById('watch').hidden"), 'a garden without a demonstration has no Watch button')
await page.evaluate("window.__samon.load(0)")
far = await centers(page, [[0, len(g0['rows'][0]) - 1]])
await page.mouse.click(*far[0]); st = await state(page)
expect('Start at the gate' in st['status'] and st['len'] == 0, 'a press away from the gate says where to start')
gp = await page.evaluate("(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); const s = r.width / cv.width; const [x, y] = window.__samon.gate(); return [r.left + x * s, r.top + y * s]; })()")
pts = await centers(page, g0['intended'][:3])
await page.mouse.move(*gp); await page.mouse.down()
for (x, y) in pts: await page.mouse.move(x, y, steps=3)
await page.mouse.up(); st = await state(page); ob = await page.evaluate(onboard)
expect(st['len'] == 3 and not ob['beckon'] and not ob['finger'] and ob['started'] == '1', f'dragging the monk in from the gateway starts the walk, and the beckoning stops {ob}')
await page.screenshot(path=SHOT + 'shot-phone-light-start.png', full_page=False)
await page.evaluate("window.__samon.load(2)"); ob = await page.evaluate(onboard)
expect(ob['beckon'] and not ob['finger'], f'after the first walk, fresh gardens still beckon, without the finger {ob}')
# 1. every garden at par, by mouse: letting go of a complete walk rakes it
for i, g in enumerate(G if not os.environ.get('SAMON_QUICK') else G[:3]):
await page.evaluate(f"window.__samon.load({i})")
await drag(page, g['intended'])
await page.wait_for_timeout(350)
st = await state(page)
ok = st['len'] == len(g['intended']) and st['turns'] == g['par'] and st['mode'] == 'reveal'
pts = await centers(page, [g['intended'][0]]); await page.mouse.click(*pts[0]) # skip the reveal
await page.wait_for_timeout(60)
st2 = await state(page)
expect(ok and st2['mode'] == 'done' and st2['seal'] == 'grid' and 'par' in st2['status'], f"{g['name']}: drawn, raked on letting go, at par, sealed")
# 2. refusals: wide rake U-turn; pebbles
wi = next(i for i, g in enumerate(G) if g['name'] == 'Wide rake')
await page.evaluate(f"window.__samon.load({wi})")
await drag(page, [[6, 5], [5, 5], [4, 5], [4, 6], [5, 6]])
st = await state(page)
expect('wide rake' in st['status'] and st['len'] == 4, 'wide rake refuses an open-sand U-turn')
pi = next(i for i, g in enumerate(G) if g['name'] == "The abbot's pebbles")
pg = G[pi]; marks = {tuple(map(int, k.split(','))): v for k, v in pg['marks'].items()}
rows = pg['rows']; seq = [tuple(c) for c in pg['intended']]
j = next(k for k in range(1, len(seq) - 1) if marks.get(seq[k]) == 'S'
and any(0 <= seq[k][0] + a < len(rows) and 0 <= seq[k][1] + bb < len(rows[0]) and rows[seq[k][0] + a][seq[k][1] + bb] == '.'
and (seq[k][0] + a, seq[k][1] + bb) not in seq[:k + 1]
and (a, bb) != (seq[k][0] - seq[k - 1][0], seq[k][1] - seq[k - 1][1])
and (a, bb) != (seq[k - 1][0] - seq[k][0], seq[k - 1][1] - seq[k][1]) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1))))
v = seq[j]; din = (v[0] - seq[j - 1][0], v[1] - seq[j - 1][1])
turn = next((v[0] + a, v[1] + bb) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1))
if (a, bb) != din and (a, bb) != (-din[0], -din[1]) and 0 <= v[0] + a < len(rows) and 0 <= v[1] + bb < len(rows[0])
and rows[v[0] + a][v[1] + bb] == '.' and (v[0] + a, v[1] + bb) not in seq[:j + 1])
await page.evaluate(f"window.__samon.load({pi})")
await drag(page, [list(c) for c in seq[:j + 1]] + [list(turn)])
st = await state(page)
expect('pale pebble' in st['status'] and st['len'] == j + 1, 'a pale pebble refuses a turn')
# 3. Restore after a tap-rewind and after Start over
ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm')
await page.evaluate(f"window.__samon.load({ci})")
await page.click('#restart')
await drag(page, G[ci]['intended'][:12])
pts = await centers(page, [G[ci]['intended'][3]]); await page.mouse.click(*pts[0])
st = await state(page)
expect(st['len'] == 4 and st['restore'], 'tapping a footprint rewinds to it, and offers Restore')
await page.click('#restore'); st = await state(page)
expect(st['len'] == 12 and not st['restore'], 'Restore brings the rewound route back')
await page.click('#restart'); st = await state(page)
expect(st['len'] == 0 and st['restore'], 'Start over clears the route, and offers Restore')
await page.click('#restore'); st = await state(page)
expect(st['len'] == 12, 'Restore after Start over')
# 3b. after raking: a tap on a cell walks back to it (the sand is smoothed back to the footprints), Restore rakes it again;
# holding at the last cell does not rake until the mouse lets go; the keyboard rakes after a beat
await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart')
full = G[ci]['intended']
pts = await centers(page, full)
await page.mouse.move(*pts[0]); await page.mouse.down()
for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3)
await page.wait_for_timeout(700); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == len(full), 'a complete walk is not raked while the mouse is still down')
await page.mouse.up(); await page.wait_for_timeout(350); st = await state(page)
expect(st['mode'] == 'reveal', 'letting go starts the raking')
await page.mouse.click(*pts[0]); await page.wait_for_timeout(700)
k = len(full) - 5
await page.mouse.click(*pts[k - 1]); await page.wait_for_timeout(100); st = await state(page)
ref = await page.evaluate("window.__samon.sand()")
expect(st['mode'] == 'draw' and st['len'] == k and st['seal'] == 'none' and st['restore'], 'after raking, tapping a cell walks back to it, and offers Restore')
await page.evaluate("(w) => window.__samon.setPath(w)", full[:k]); fresh = await page.evaluate("window.__samon.sand()")
expect(abs(ref - fresh) < 1e-6, 'walking back smooths the raked sand back to the footprints')
await page.evaluate("(w) => window.__samon.setPath(w)", full)
await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'a walk completed any other way is raked after a beat')
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(60); st = await state(page)
expect(st['mode'] == 'done', 'Enter skips the raking')
await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == len(full) - 1, 'Backspace after raking steps back one footprint')
await page.click('#restore'); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'Restore brings the whole walk back, and it is raked again')
await page.keyboard.press('Enter'); await page.wait_for_timeout(60)
# the keyboard: the last arrow rakes after a beat, in which Backspace can still take it back
ti = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares')
await page.evaluate(f"window.__samon.load({ti})"); await page.focus('#garden')
keys = ('ArrowUp', 'ArrowUp', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowUp') # in at the gate, then a serpentine
for kk in keys: await page.keyboard.press(kk)
await page.keyboard.press('Backspace'); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == 8, 'Backspace in the beat takes the last step back, and nothing is raked')
await page.keyboard.press(keys[-1]); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] in ('reveal', 'done') and st['len'] == 9, 'from the keyboard, a complete walk is raked after a beat')
await page.keyboard.press('Enter'); await page.wait_for_timeout(60); await page.keyboard.press('Enter'); await page.wait_for_timeout(800)
nm = await page.evaluate("window.__samon.G.name")
expect(nm == G[ti + 1]['name'], 'Enter after raking goes on to the next garden')
# a walk that covers every cell but may not end where it does is not raked
wi2, bad = None, None
for j, gg in enumerate(G):
if not gg['wide'] or gg['end']: continue
gx = garden_of(gg, braced=False)
for s_ in gx.solve(mode=0, cap=400, stream=True)['sols']:
if not check_solution(garden_of(gg), s_['draw']): wi2, bad = j, [list(c) for c in s_['draw']]; break
if bad: break
await page.evaluate(f"window.__samon.load({wi2})"); await drag(page, bad); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == len(bad) and 'wide rake down' in st['status'], f"a complete walk ending in open sand under the wide rake is not raked ({G[wi2]['name']})")
# 4. the abbot's hints
await page.evaluate(f"window.__samon.load({ci})")
await page.click('#restart'); await page.click('#hint'); st = await state(page)
expect(G[ci]['hint'] in st['status'], 'first hint is the authored observation')
await page.click('#hint'); st = await state(page)
expect('at par' in st['status'] and st['ghosts'] == 1, 'second hint: route can reach par, one groove sketched')
found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30);
const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", found)
await page.click('#hint'); st = await state(page)
expect('no longer at par' in st['status'] and any('for par' in a for a in st['actions']), "'no longer at par' with a rewind offer")
await page.click('#actions button >> nth=0'); st = await state(page)
a = await page.evaluate("window.__samon.analyze(window.__samon.path).state")
expect(a == 'par' and st['restore'], 'the par rewind lands on a route that can reach par, and is restorable')
fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent.replace(/^\\[|\\]$/g, '')]")
n0 = await page.evaluate("window.__samon.path.length"); await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowRight'); n1 = await page.evaluate("window.__samon.path.length")
expect(fk[0] == 'garden' and fk[1].startswith('Stepped back') and 'par is still possible' in fk[1] and 'no longer at par' not in fk[1] and n1 > n0,
f"after the abbot's rewind the garden has the keyboard again, and the old message has given way to what now holds ({fk[1][:70]!r})")
found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 8 + s % 30);
const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'stuck' && a.back < w.length - 1 && a.why.cells.length) return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", found)
await page.click('#hint'); st = await state(page)
expect('to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), "'stuck': reason, marked cells, rewind offer, action first")
await page.screenshot(path=SHOT + 'shot-phone-light-stuck.png', full_page=True)
await page.click('#actions button >> nth=0')
a = await page.evaluate("window.__samon.analyze(window.__samon.path).state")
fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent]")
expect(a in ('par', 'finish') and fk[0] == 'garden' and 'can be finished from here' in fk[1] and 'marked footprint' not in fk[1], f"the rewind lands on a route that can be finished, says so, and keeps the keyboard ({fk[1][:60]!r})")
# 5. assists
await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart')
await drag(page, G[ci]['intended'][:20] if False else [[6, 3], [5, 3], [5, 2], [5, 1], [5, 0], [4, 0], [3, 0], [3, 1], [4, 1], [4, 2], [4, 3], [4, 4], [3, 4], [3, 5]])
for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid)
pressed = await page.evaluate("['aPreview','aChecker','aCut'].map(id => document.getElementById(id).getAttribute('aria-pressed'))")
expect(pressed == ['true'] * 3, 'assist toggles switch on')
await page.screenshot(path=SHOT + 'shot-phone-light-assists.png', full_page=True)
for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid)
# 6. practice garden
await page.click('text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view
s0 = await page.evaluate("window.__samon.practice()")
box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()")
L, T, W, H = box
import math
for k, (cx, cy, rad) in enumerate([(0.27, 0.40, 0.18), (0.72, 0.65, 0.14)]):
pts = [(L + W * (cx + rad * math.cos(t / 30 * 2 * math.pi) * 0.75), T + H * (cy + rad * math.sin(t / 30 * 2 * math.pi))) for t in range(0, 34)]
await page.mouse.move(*pts[0]); await page.mouse.down()
for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=4)
await page.mouse.up()
for yy in (0.1, 0.16, 0.86, 0.92):
await page.mouse.move(L + W * 0.05, T + H * yy); await page.mouse.down()
await page.mouse.move(L + W * 0.95, T + H * yy, steps=40); await page.mouse.up()
s1 = await page.evaluate("window.__samon.practice()")
expect(abs(s1['sum'] - s0['sum']) > 50, 'raking changes the practice sand')
await page.screenshot(path=SHOT + 'shot-phone-light-practice.png', full_page=True)
await page.click('#toolStone'); await page.mouse.click(L + W * 0.5, T + H * 0.2)
s2 = await page.evaluate("window.__samon.practice()")
expect(s2['stones'] == s0['stones'] + 1, 'Stones tool places a stone')
await page.mouse.click(L + W * 0.5, T + H * 0.2)
s3 = await page.evaluate("window.__samon.practice()")
expect(s3['stones'] == s0['stones'], 'tapping a stone lifts it')
await page.click('#smooth'); s4 = await page.evaluate("window.__samon.practice()")
expect(abs(s4['sum'] - s0['sum']) < 1e-6, 'Smooth restores the unraked sand')
await page.click('#nav [aria-label="Calm"]'); st = await state(page)
expect(await page.evaluate("window.__samon.view") == 'puzzle', 'back from the practice garden to a garden')
await ctx.close()
# 7. dark mode: a finished garden, and the practice garden
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='dark')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page, delay=100, hold=3600000) # the screenshot shows the raked garden with its poem
si = next(i for i, g in enumerate(G) if g['name'] == 'Spiral')
await page.evaluate(f"window.__samon.load({si})"); await drag(page, G[si]['intended'])
await page.wait_for_timeout(7200)
await page.screenshot(path=SHOT + 'shot-phone-dark-done.png', full_page=True)
await page.evaluate("window.__samon.enterPractice()")
box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()")
L, T, W, H = box
for yy in (0.25, 0.42, 0.59, 0.76):
await page.mouse.move(L + W * 0.06, T + H * yy); await page.mouse.down()
await page.mouse.move(L + W * 0.94, T + H * (yy + 0.04), steps=40); await page.mouse.up()
await page.screenshot(path=SHOT + 'shot-phone-dark-practice.png', full_page=True)
await ctx.close()
# 7b. the grey seal: help beyond the abbot's first observation marks the raking (fresh profile)
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm')
ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares')
async def rake_intended(i):
await drag(page, G[i]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60)
return await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]")
async def pill_class(name):
return await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(name)}).className")
await page.evaluate(f"window.__samon.load({ni})")
await page.click('#hint') # the authored observation only: free
r1 = await rake_intended(ni)
expect(r1[0] == 'grid' and not r1[1] and 'grey' not in r1[2], 'the first observation alone leaves the seal vermilion')
await page.evaluate(f"window.__samon.load({ci})")
await page.click('#hint'); await page.click('#hint') # observation, then a verdict: help
st = await state(page)
expect('grey seal' in st['status'], 'the first real help says the seal will be grey')
await page.click('#restart') # starting over does not undo help
r2 = await rake_intended(ci)
expect(r2[0] == 'grid' and r2[1] and 'grey' in r2[2], 'a helped raking at par earns the grey seal, even after Start over')
expect('grey' in await pill_class('Calm'), 'the garden list shows a grey seal')
await page.click('#restart')
r3 = await rake_intended(ci)
expect(r3[0] == 'grid' and not r3[1] and 'grey' not in r3[2], 'raking it again unaided earns the vermilion seal')
expect('sealed' in await pill_class('Calm'), 'the garden list now shows the vermilion seal')
sand0 = await page.evaluate("window.__samon.sand()")
await page.click('#restart')
sand1 = await page.evaluate("window.__samon.sand()")
expect(abs(sand0 - sand1) > 1, 'Start over smooths the raked sand back to unraked')
# help travels with the route it helped: stepping back into a helped raking and restoring it re-rakes it grey
ti = next(i for i, g in enumerate(G) if g['name'] == 'The pocket')
await page.evaluate(f"window.__samon.load({ti})")
await page.click('#hint'); await page.click('#hint')
r4 = await rake_intended(ti)
await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60)
h = await page.evaluate("window.__samon.helped")
async def rerake(): # Restore rakes again after a beat; Enter (on the garden) skips it
await page.click('#restore')
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
if await page.evaluate("window.__samon.mode") == 'reveal':
await page.focus('#garden'); await page.keyboard.press('Enter')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=3000)
await rerake()
r5 = await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]")
expect(r4[1] and h and r5[0] == 'grid' and r5[1] and 'grey' in r5[2], f'Step back, then Restore, re-rakes a helped raking with the grey seal {r5}')
tp = await centers(page, [G[ti]['intended'][2]]); await page.wait_for_timeout(700); await page.mouse.click(*tp[0])
await page.wait_for_function("window.__samon.mode === 'draw'", timeout=3000)
await rerake()
r6 = await page.evaluate("document.getElementById('seal').classList.contains('grey')")
expect(r6, 'so does tapping back into it, then Restore')
# an answer the abbot can't give in time costs nothing
await page.evaluate(f"window.__samon.load({ci})")
found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30);
const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", found)
h0 = await page.evaluate("window.__samon.helped")
await page.evaluate("window.__samon.budget = 1"); await page.click('#hint')
st = await state(page); h1 = await page.evaluate("window.__samon.helped")
await page.evaluate("window.__samon.budget = 250000")
expect(not h0 and "can't be sure" in st['status'] and 'costs you nothing' in st['status'] and not h1, f"an inconclusive answer costs nothing ({st['status']!r})")
# a walk that has stopped: why is free, where to step back to is help
dead = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 999);
if (w.length < window.__samon.G.nSand && w.length > 3) return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", dead); await page.click('#hint')
st = await state(page); h2 = await page.evaluate("window.__samon.helped")
expect('Saying why a walk has stopped is free' in st['status'] and not h2 and st['actions'] == ['Where can I step back to?'],
f"a stopped walk: the abbot says why for free ({st['status']!r}, {st['actions']})")
await page.click('#actions button >> nth=0')
st = await state(page); h3 = await page.evaluate("window.__samon.helped")
expect(h3 and 'to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), 'asking where to step back to is help')
await ctx.close()
# 7c. worlds open as the world before them is raked (two-thirds of it, at any turn count); the prototype can open all
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
w1 = [i for i, g in enumerate(G) if g['world'] == 1]
w1m = [i for i in w1 if not G[i]['extra']]; need = -(-2 * len(w1m) // 3)
w1x = len(w1) - len(w1m)
nx = await page.evaluate("[document.querySelectorAll('#nav .pip').length, document.querySelectorAll('#nav .pip.extra').length, [...document.querySelectorAll('#nav .fold')].map(b => b.textContent)]")
await page.click('#nav .fold'); await page.wait_for_timeout(100)
nx2 = await page.evaluate("[document.querySelectorAll('#nav .pip.extra').length, document.querySelectorAll('#nav .more').length, document.querySelector('#nav .fold').textContent]")
await page.evaluate(f"window.__samon.load({w1[-1]})"); await page.wait_for_timeout(100) # (an extra of Dawn open: its row unfolds by itself)
nx3 = await page.evaluate("[document.querySelectorAll('#nav .pip.extra').length, document.querySelector('#nav .pip[aria-current=\"true\"]').getAttribute('aria-label')]")
await page.click('#nav .fold'); await page.evaluate("window.__samon.load(0)"); await page.wait_for_timeout(100)
nx4 = await page.evaluate("document.querySelectorAll('#nav .pip.extra').length")
expect(nx == [len(w1m), 0, [f'+{w1x} more']] and nx2 == [w1x, 1, 'fewer'] and nx3 == [w1x, G[w1[-1]]['name']] and nx4 == 0,
f"a world's extras fold behind '+{w1x} more' (unfolding by themselves while one of them is open), and only open worlds show pips ({nx}, {nx2}, {nx3}, {nx4})")
lk = await page.evaluate("[...document.querySelectorAll('#nav .world.locked')].map(r => [r.querySelector('.t').textContent, r.querySelector('.lock .n').textContent, r.querySelector('.lock .note').textContent])")
expect(len(lk) == 6 and lk[0][0] == 'Morning' and f'{need} gardens of Dawn' in lk[0][2] and all(n[1] == f"{sum(1 for g in G if g['world'] == k + 2)} gardens ·" for k, n in enumerate(lk)) and all(n[2] == '[Locked.]' for n in lk[1:]),
f'at first only Dawn is open; each locked world is one line with its count and what opens it {lk[:2]}')
await page.click('#openAll')
locked2 = await page.evaluate("[document.querySelectorAll('#nav .world.locked').length, document.querySelectorAll('#nav .pip').length]")
await page.click('#openAll')
locked3 = await page.evaluate("document.querySelectorAll('#nav .world.locked').length")
expect(locked2 == [0, sum(1 for g in G if not g['extra'])] and locked3 == 6, f'the prototype switch opens every world (main paths shown, extras folded), and locks them again {locked2} {locked3}')
for k, i in enumerate(w1m[:need]):
await page.evaluate(f"window.__samon.load({i})"); await page.click('#restart')
await drag(page, G[i]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60)
if k == need - 2:
still = await page.evaluate("window.__samon.worldOpen(2)")
st = await state(page)
open2 = await page.evaluate("[window.__samon.worldOpen(2), window.__samon.worldOpen(3), document.querySelectorAll('#nav .world.locked').length]")
expect(not still and open2[0] and not open2[1] and 'Morning is open' in st['status'] and open2[2] == 5,
f'raking {need} of the {len(w1m)} Dawn gardens on the main path opens Morning, and says so {open2}')
# Next follows the main path: from the last Dawn garden on it, past the extras, to the first of Morning; that
# garden introduces a rule, and its demonstration plays by itself
last1 = w1m[-1]
await page.evaluate(f"window.__samon.load({last1})"); await page.click('#restart')
await drag(page, G[last1]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[last1]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100)
await page.click('#next'); await page.wait_for_timeout(800)
nm = await page.evaluate("window.__samon.G.name")
first2 = next(g for g in G if g['world'] == 2 and not g['extra'])
expect(nm == first2['name'], f'Next from the end of the main path skips the extras ({nm})')
if not first2['demo']:
i2 = G.index(first2)
await drag(page, first2['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [first2['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100)
await page.click('#next'); await page.wait_for_timeout(800)
nm2 = await page.evaluate("window.__samon.G.name")
dg = next(g for g in G if g['name'] == nm2)
await page.wait_for_timeout(700)
expect(dg['demo'] and await page.evaluate("window.__samon.demoRunning"), f'the garden that brings in the ripple stone demonstrates itself ({nm2})')
await page.mouse.click(*(await centers(page, [dg['intended'][0]]))[0]); await page.wait_for_timeout(100)
# the abbot's observation points at its cells, and they stay marked while the walk goes on
pk = next(i for i, g in enumerate(G) if g['name'] == 'The pocket')
await page.evaluate(f"window.__samon.load({pk})"); await page.click('#restart'); await page.click('#hint')
st = await state(page); ob = await page.evaluate("window.__samon.obs.length")
await drag(page, G[pk]['intended'][:3]); ob2 = await page.evaluate("window.__samon.obs.length")
expect('He points' in st['status'] and ob == 1 and ob2 == 1, 'the observation points at its cell, which stays marked while drawing')
await ctx.close()
# 8. desktop: the pebbles garden mid-route
ctx = await b.new_context(viewport={'width': 1200, 'height': 1000}, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
await page.evaluate(f"window.__samon.load({pi})"); await drag(page, G[pi]['intended'][:15])
await page.screenshot(path=SHOT + 'shot-desktop-light.png', full_page=True)
await ctx.close()
# 9. properties, over every garden
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
bad_bends = []
for i, g in enumerate(G):
await page.evaluate(f"window.__samon.load({i})")
arcs, turns = await page.evaluate("(c) => window.__samon.strokeBends(c)", g['intended'])
if not (arcs == turns == g['par']): bad_bends.append((g['name'], arcs, turns, g['par']))
nside = sum(1 for g in G if g['end'])
expect(not bad_bends, f'every garden: the bends drawn in the raked stroke = the turns counted = par ({nside} with a side gate) {bad_bends[:5]}')
piv = await page.evaluate("""(n) => { let pairs = 0, bad = 0; for (let i = 0; i < n; i++) { window.__samon.load(i); const G = window.__samon.G;
if (!G.wide) continue;
for (const v of G.sandList) for (let d = 0; d < 4; d++) { const r = Math.floor(v / G.W) + [-1, 0, 1, 0][d], c = v % G.W + [0, 1, 0, -1][d];
if (r >= 0 && r < G.H && c >= 0 && c < G.W && G.rows[r][c] === 'R') { pairs++; if (G.piv[v * 4 + d]) bad++; } } }
return [pairs, bad]; }""", len(G))
expect(piv[0] > 0 and piv[1] == 0, f'under the wide rake, finished sand is never a pivot ({piv[0]} cell sides checked)')
wrong, seen = [], 0
quick = bool(os.environ.get('SAMON_QUICK')) # the abbot's explanations: 400 random routes per garden in full, a sample when quick
for i, g in enumerate(G):
if not (g['end'] or g['wide'] or g.get('marks')): continue
res = await page.evaluate(f"""(() => {{ const out = []; for (let s = 1; s < {30 if quick else 400}; s++) {{ const w = window.__samon.randomWalk({i}, s, 999);
const a = window.__samon.analyzeFor({i}, w); if (a.state === 'stuck' && a.why && /only one way in, so the walk would have to end there/.test(a.why.text))
out.push([a.why.text, a.why.cells[0]]); }} return out; }})()""")
W_ = len(g['rows'][0]); end = g['end'][0] * W_ + g['end'][1] if g['end'] else -1
marks = {int(k.split(',')[0]) * W_ + int(k.split(',')[1]) for k in (g.get('marks') or {})}
for text, cell in res:
seen += 1
want = 'side gate' if end >= 0 and cell != end else 'pebble' if cell in marks else 'wide rake'
if want not in text: wrong.append((g['name'], text))
expect(seen > 0 and not wrong, f'a dead-end cell is explained by the rule that actually fails ({seen} explanations checked) {wrong[:3]}')
await ctx.close()
# 10. the poem is the reward: after any raking (here off par, and with the abbot's help) a poem is laid over the
# garden, and then the next garden fades in; a tap on the poem goes on, a tap beside it stays; 'original' shows the Japanese; Enter goes on at once; a
# demonstration earns no poem; the practice garden has one
PO = json.load(open('/home/claude/zen/poems/samon-poems.json'))
ppoem = lambda name: PO['poems'][PO['gardens'][name]]
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
pc = await page.evaluate("window.__samon.poems()")
expect(pc['gardens'] == len(G) and pc['practice'], f'every garden has a poem, and so does the practice garden {pc}')
await quiet(page, delay=200, hold=1500, fade=300)
ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares')
gx = garden_of(G[ni])
sub = next(s_['draw'] for s_ in gx.solve(mode=0, cap=100, stream=True)['sols'] if turns_of(gx, s_['draw']) > G[ni]['par'])
await page.evaluate(f"window.__samon.load({ni})")
await page.click('#hint'); await page.click('#hint')
await drag(page, [list(c) for c in sub]); await page.wait_for_timeout(350)
pts = await centers(page, [list(sub[0])]); await page.mouse.click(*pts[0])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(400)
po = await page.evaluate("window.__samon.poem")
expect(po['visible'] and po['verse'] == ppoem('Nine squares')['translation'], f"a raking off par, made with help, still earns its poem ({po['verse'][:40]!r})")
nxt = G[ni + 1]['name']
await page.wait_for_function("window.__samon.poem.leaving", timeout=4000)
await page.wait_for_function(f"window.__samon.G.name === {json.dumps(nxt)}", timeout=3000)
st = await state(page); po = await page.evaluate("window.__samon.poem")
expect(st['mode'] == 'draw' and po['state'] == 'none' and not po['visible'], f'then garden and poem fade, and the next garden fades in ({nxt})')
i2 = ni + 1
await quiet(page, delay=200, hold=1200, fade=300)
await drag(page, G[i2]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[i2]['intended'][0]]); await page.mouse.click(*pts[0])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(300)
beside = await page.evaluate("(() => { const r = document.getElementById('poem').getBoundingClientRect(), c = document.querySelector('#poem .card').getBoundingClientRect(); return [r.left + 3, c.top > r.top + 6 ? r.top + 3 : r.top + r.height - 3]; })()")
await page.mouse.click(*beside); await page.wait_for_timeout(2000)
st = await state(page); po = await page.evaluate("window.__samon.poem"); nm = await page.evaluate("window.__samon.G.name")
expect(nm == G[i2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Staying' in st['status'] and 'Read the poem again' in st['actions'],
f"a tap beside the poem puts it away and stays with the garden ({st['status'][-60:]!r})")
await page.click('#actions button:has-text("Read the poem again")'); await page.wait_for_timeout(1800)
po = await page.evaluate("window.__samon.poem")
expect(po['visible'], 'the poem can be read again')
await page.click('#orig'); await page.wait_for_timeout(100)
po = await page.evaluate("window.__samon.poem")
expect(po['ja'] == ppoem(G[i2]['name'])['japanese'] and not po['clash'], "'original' shows the Japanese (and no line runs under the seal)")
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(800)
nm = await page.evaluate("window.__samon.G.name")
expect(nm == G[i2 + 1]['name'], 'Enter goes on to the next garden at once')
await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 1]['intended'])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(200)
card = await page.evaluate("(() => { const c = document.querySelector('#poem .card').getBoundingClientRect(); return [c.left + c.width / 2, c.top + c.height / 2]; })()")
await page.mouse.click(*card); await page.wait_for_timeout(900)
nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem")
expect(nm == G[i2 + 2]['name'] and po['state'] == 'none', f'a tap on the poem itself goes on to the next garden at once ({nm!r})')
await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 2]['intended'])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(700) # (a tap within 0.6 s of the raking is taken as a skip of it)
wall = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + 4, r.top + 4]; })()")
await page.mouse.click(*wall); await page.wait_for_timeout(300)
nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem"); st = await state(page)
expect(nm == G[i2 + 2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Read the poem again' in st['actions'], f'a tap on the walls around the poem puts it away and stays ({nm!r}, {po["state"]})')
fit = await page.evaluate(f"""(() => {{ const out = [], fs = [], moved = [0, 0]; for (let i = 0; i < {len(G)}; i++) for (const o of [false, true]) {{
const r = window.__samon.poemFit(i, o); fs.push(r.fs); moved[0] += r.left; moved[1] += r.under;
if (r.over || (r.clash && !r.under) || (r.under && !o)) out.push([i, o, r]); }} return [out, Math.min(...fs), moved]; }})()""")
expect(not fit[0], f'on a phone, every poem fits its garden and clears the seal (set flush left {fit[2][0]} times, over a faded seal {fit[2][1]} times, '
f'only with the original; smallest type {fit[1]:.1f}px) {fit[0][:3]}')
await page.evaluate("window.__samon.load(0)"); await page.evaluate("window.__samon.startDemo()")
await page.wait_for_function("document.getElementById('status').textContent.startsWith('[That is')", timeout=15000)
po = await page.evaluate("window.__samon.poem")
expect(po['state'] == 'none' and not po['visible'], 'a demonstration earns no poem')
await page.evaluate("window.__samon.enterPractice()")
pp = await page.evaluate("document.getElementById('ppoem').textContent")
expect(PO['poems'][PO['practice']]['translation'].split('\n')[0] in pp, 'the practice garden has its poem')
await ctx.close()
# 11. the scroll of poems earned, and the daily garden (one for everyone, picked by the date; raked once, by Rake)
DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json'))
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page, delay=200, hold=3600000)
await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150)
sc0 = await page.evaluate("[document.querySelectorAll('#scroll .entry').length, getComputedStyle(document.getElementById('scroll')).display !== 'none', getComputedStyle(document.getElementById('stage')).display, window.__samon.view]")
expect(sc0 == [1, True, 'none', 'poems'], f'the scroll before any raking: only the practice poem, shown in place of the garden {sc0}')
for i in (0, 1):
await page.evaluate(f"window.__samon.load({i})")
await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150)
sc = await page.evaluate("[[...document.querySelectorAll('#scroll .entry .verse')].map(e => e.textContent), [...document.querySelectorAll('#scroll .entry .ja')].map(e => e.textContent), [...document.querySelectorAll('#scroll .left')].map(e => e.textContent)]")
expect(sc[0][:2] == [ppoem(G[0]['name'])['translation'], ppoem(G[1]['name'])['translation']] and sc[1][:2] == [ppoem(G[0]['name'])['japanese'], ppoem(G[1]['name'])['japanese']] and len(sc[0]) == 3,
f'two gardens raked: their poems, in order, with the Japanese, then the practice poem ({len(sc[0])})')
expect(sc[2][0].startswith('[2 of') and sc[2][1] == f"[{sum(1 for g in G if g['world'] == 1) - 2} more in the gardens of Dawn.]", f'the scroll counts what is earned and what is left, in editorial brackets {sc[2][:2]}')
pills = await page.evaluate("[...document.querySelectorAll('#nav .pill')].map(b => b.textContent)")
sw = await page.evaluate("[document.getElementById('soundToggle').textContent, document.getElementById('soundPanel').hidden]")
expect(any(t.startswith('Poems · 2 of') for t in pills) and pills[0].startswith("Today's garden") and sw[0] in ('Sound on', 'Sound off') and sw[1], f'the nav offers the poems and the daily garden; the sound switch waits under the speaker {pills} {sw}')
await page.click('#scroll .entry .by button >> nth=1'); await page.wait_for_timeout(150)
v = await page.evaluate("[window.__samon.view, window.__samon.G.name]")
expect(v == ['puzzle', G[1]['name']], f"a garden's name in the scroll opens it {v}")
expect(len(DAILY) == 366 and all(e['world'] == 0 and e['intended'] and e['par_moves'] for e in DAILY), f'a year of daily gardens is embedded ({len(DAILY)})')
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
d = await page.evaluate("window.__samon.daily")
expect(d['year'] == len(DAILY) and d['key'] == '2026-10-04' and d['dayNo'] == 276 and not d['done'], f'the daily slot: day 276 of the year, not yet raked {d}')
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(250)
g = await page.evaluate("window.__samon.G")
day = DAILY[276 % len(DAILY)]
hdr = await page.evaluate("[document.getElementById('time').textContent, document.getElementById('gname').textContent, document.getElementById('next').textContent, document.getElementById('rakeDay').hidden]")
expect(g['rows'] == day['rows'] and hdr == ['Daily garden', '4 October 2026', 'Back to the gardens', True], f'today\'s garden is the year\'s day 276, under its date {hdr}')
await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(900)
st = await page.evaluate("[window.__samon.mode, document.getElementById('rakeDay').hidden, document.getElementById('status').textContent]")
expect(st[0] == 'draw' and not st[1] and 'press Rake' in st[2], f'a complete walk is not raked by itself here: it waits for Rake {st}')
await page.click('#rakeDay')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000)
await page.wait_for_function("window.__samon.poem.visible", timeout=5000)
d2 = await page.evaluate("window.__samon.daily")
st2 = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.poem.state, document.getElementById('seal').hidden]")
expect(d2['done'] and d2['streak'] == 1 and d2['turns'] == day['par'] and d2['kind'] == 'red' and 'Streak: 1 day' in st2[0] and st2[1] == ["Share today's score", 'Read the poem again'] and st2[2] == 'stay' and not st2[3],
f'raked at par: the record, a streak of one, the seal, the share action; the poem stays {d2} {st2}')
box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()")
await page.mouse.click(*box); await page.wait_for_timeout(250); await page.mouse.click(*box); await page.wait_for_timeout(250)
st3 = await page.evaluate("[window.__samon.mode, document.getElementById('undo').disabled, document.getElementById('restart').disabled]")
expect(st3 == ['done', True, True], f'the raking is final: no walking back, Step back and Start over off {st3}')
await page.reload(); await page.wait_for_timeout(400)
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300)
st4 = await page.evaluate("[window.__samon.mode, document.getElementById('status').textContent, document.getElementById('seal').hidden, window.__samon.daily.streak, window.__samon.path.length]")
expect(st4[0] == 'done' and "Today's record" in st4[1] and not st4[2] and st4[3] == 1 and st4[4] == len(day['intended']), f'after a reload the day\'s raking and its record are back {st4[:4]}')
await page.evaluate("window.__samon.today = '2026-10-05T12:00:00'")
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300)
d3 = await page.evaluate("window.__samon.daily"); g3 = await page.evaluate("window.__samon.G.rows")
expect(d3['key'] == '2026-10-05' and not d3['done'] and d3['streak'] == 1 and g3 == DAILY[277 % len(DAILY)]['rows'] and await page.evaluate("window.__samon.mode") == 'draw', f'the next day is a fresh garden, and the streak counts yesterday {d3}')
nx = await page.evaluate("(() => { window.__samon.load(0); document.getElementById('next').click(); return window.__samon.G.name; })()")
expect(nx != d3['name'] and nx == G[1]['name'], f'the daily garden is never the next garden ({nx})')
await ctx.close()
# 12. drag intent: the walk follows the axis the hand moves on, not the cell under the pointer; the grip survives the walls
ctx = await b.new_context(viewport={'width': 700, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200); await quiet(page)
gi = next(i for i, g in enumerate(G) if len(g['rows']) >= 6 and len(g['rows'][0]) >= 7 and g['gate'][2] == 'S' and g['gate'][0] == len(g['rows']) - 1
and set(g['rows'][-1] + g['rows'][-2]) == {'.'} and g['rows'][-3][g['gate'][1] + 3] == '.')
H, W = len(G[gi]['rows']), len(G[gi]['rows'][0]); gr, gc = G[gi]['gate'][:2]
async def at(r, c, dx=0.0, dy=0.0):
return await page.evaluate("""([r, c, dx, dy]) => { const cv = document.getElementById('garden'); const b = cv.getBoundingClientRect();
const s = b.width / cv.width; const g = window.__samon.geom();
return [b.left + (g.ox + (c + 0.5 + dx) * g.cs) * s, b.top + (g.oy + (r + 0.5 + dy) * g.cs) * s]; }""", [r, c, dx, dy])
async def walk():
return await page.evaluate("window.__samon.path.map(k => [Math.floor(k / window.__samon.G.W), k % window.__samon.G.W])")
async def begin():
await page.evaluate(f"window.__samon.load({gi}); window.__samon.setPath([]); document.getElementById('garden').scrollIntoView({{block: 'center'}})"); await page.wait_for_timeout(50)
x, y = await at(gr, gc); await page.mouse.move(x, y); await page.mouse.down()
run = [[gr, c] for c in range(gc, gc + 4)]
await begin(); x, y = await at(gr, gc + 3, 0, -0.9); await page.mouse.move(x, y, steps=40); await page.mouse.up()
expect(await walk() == run, f'a drag along the row whose hand drifts 0.9 of a cell into the next row still draws the row ({await walk()})')
await begin(); x, y = await at(gr, gc + 1, 0, 1.6); await page.mouse.move(x, y, steps=12); x, y = await at(gr, gc + 3, 0, 1.8); await page.mouse.move(x, y, steps=30); await page.mouse.up()
expect(await walk() == run, f'a pointer below the wall keeps its grip and the walk follows along the row ({await walk()})')
await begin(); x, y = await at(gr, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=40)
for k in range(1, 13): # a U-turn: a semicircle within the last cell of the run, up into the next row
a = math.pi / 2 - math.pi * k / 12
x, y = await at(gr, gc + 3, 0.1 + 0.35 * math.cos(a), -0.35 + 0.35 * math.sin(a)); await page.mouse.move(x, y)
x, y = await at(gr - 1, gc); await page.mouse.move(x, y, steps=40); await page.mouse.up()
expect(await walk() == run + [[gr - 1, c] for c in range(gc + 3, gc - 1, -1)], f'a U-turn drawn as an arc turns once and comes back along the next row ({await walk()})')
await begin(); x, y = await at(gr, gc + 3, -0.2, 0.05); await page.mouse.move(x, y, steps=30)
for k in range(1, 9): # a rounded corner from heading east to heading north
a = (math.pi / 2) * k / 8
x, y = await at(gr, gc + 3, -0.2 + 0.3 * math.sin(a), 0.05 - 0.3 + 0.3 * math.cos(a)); await page.mouse.move(x, y)
x, y = await at(gr - 2, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=30); await page.mouse.up()
expect(await walk() == run + [[gr - 1, gc + 3], [gr - 2, gc + 3]], f'a rounded corner is a run and a turn ({await walk()})')
await begin(); x, y = await at(gr, gc + 3, 0, -0.6); await page.mouse.move(x, y, steps=30); out = await walk()
x, y = await at(gr, gc, -0.2, -0.6); await page.mouse.move(x, y, steps=30); await page.mouse.up()
expect(out == run and await walk() == [[gr, gc]], f'dragging back along a drifted run retraces it ({out} → {await walk()})')
await begin()
for k in range(40): x, y = await at(gr, gc, 0.12 * math.cos(k), 0.12 * math.sin(k)); await page.mouse.move(x, y)
await page.mouse.up()
expect(await walk() == [[gr, gc]], f'a wobble in place takes no step ({await walk()})')
await ctx.close()
# 13. sound: nothing before a gesture; the synthesized sounds render; Sound off persists; the recordings carried in the
# page are found, credited on the scroll with their source, put down to follow a world's bell, and play
import struct
PIECES = json.load(open('/home/claude/zen/out/sound_pieces.json'))
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
s0 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.tracks]")
expect(s0 == [True, False, 'none', None], f'before any gesture there is no audio context, and the recordings are not looked at {s0}')
await page.click('#garden'); await page.wait_for_timeout(200)
s1 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.timers, window.__samon.sound.tracks.map(t => [t.title, t.src.slice(0, 22), t.dur])]")
expect(s1[:2] == [True, 'running'] and s1[2] in (6, 7) and s1[3] == [[p['title'], 'data:audio/mpeg;base64', p['dur']] for p in PIECES], f'a touch starts the audio, the six ambience loops (and, for a moment, the speaker\'s redraw), and finds the recordings in the page {s1}')
wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'drip', 0.3], [0.3, 'tock', 0.4], [0.7, 'tok', 0.4], [0.9, 'bell', 0.5]], 1.3)"))
pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:])
def peak(t0, t1): return max(abs(v) for v in pcm[int(t0 * 44100):int(t1 * 44100)]) / 32768
pk = [round(peak(*w), 3) for w in ((0.05, 0.15), (0.3, 0.4), (0.7, 0.75), (0.9, 1.2))]
expect(all(p > 0.03 for p in pk) and peak(0, 0.05) == 0, f'drip, pipe, seal and bell each render audibly, and nothing before the first {pk}')
wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'scrape', 1.2, 1], [1.5, 'crunch', 0.14], [1.8, 'birds', 0.1], [3.0, 'crickets', 0.08, 0.5], [3.7, 'wind', 0.3], [3.8, 'gust', 0.4, 1]], 5)"))
pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:])
pk = [round(peak(*w), 3) for w in ((0.3, 1.1), (1.5, 1.6), (1.85, 2.9), (3.0, 3.5), (4.3, 5.0))]
expect(all(p > 0.015 for p in pk) and peak(1.44, 1.5) < 0.02, f'the rake\'s scrape, a footstep, a bird, a cricket and the wind each render audibly, and the scrape dies away when the rake stops {pk} (tail {peak(1.44, 1.5):.3f})')
dp = await page.evaluate("[5, 6, 12, 18, 19, 20, 23, 4].map(h => window.__samon.sound.dayPart(h))")
expect(dp == ['dusk', 'day', 'day', 'day', 'dusk', 'night', 'night', 'night'], f'birds from 6 to 19, crickets from 20 to 5, by the local clock {dp}')
await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended'])
await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=3000)
sc1 = await page.evaluate("window.__samon.sound.scraping")
await page.wait_for_function("window.__samon.mode === 'done'", timeout=12000); await page.wait_for_timeout(100)
sc2 = await page.evaluate("window.__samon.sound.scraping")
expect(sc1 and not sc2, f'the scrape runs with the raking and stops with it ({sc1}, {sc2})')
await page.evaluate("window.__samon.sound.setVolume(0.3)"); await page.wait_for_timeout(300)
vs = await page.evaluate("[window.__samon.sound.vol, Math.round(window.__samon.sound.master * 100) / 100, localStorage.getItem('samon-sound-vol'), document.getElementById('soundVol') && document.getElementById('soundVol').value, !!document.getElementById('hapticsToggle'), window.__samon.sound.haptics]")
expect(vs[:3] == [0.3, 0.3, '0.3'] and vs[4] and vs[5], f'the volume is set, remembered and reaches the master gain; the haptics switch shows where the device can tick {vs}')
pn0 = await page.evaluate("document.getElementById('soundPanel').hidden")
await page.click('#soundMore'); await page.wait_for_timeout(100)
pn1 = await page.evaluate("[document.getElementById('soundPanel').hidden, document.getElementById('soundMore').getAttribute('aria-expanded'), document.getElementById('volRow').hidden]")
await page.click('#hapticsToggle'); await page.wait_for_timeout(100)
hp = await page.evaluate("[window.__samon.sound.haptics, localStorage.getItem('samon-haptics'), document.getElementById('hapticsToggle').textContent]")
expect(pn0 and pn1 == [False, 'true', False] and hp == [False, '0', 'Haptics off'], f'the chevron by the speaker opens the sound panel (switch, volume, haptics); haptics can be turned off there, and that is remembered {pn1} {hp}')
await page.mouse.click(5, 400); await page.wait_for_timeout(100)
expect(await page.evaluate("document.getElementById('soundPanel').hidden"), 'a press elsewhere closes the panel')
OPEN_PANEL = "(() => { if (document.getElementById('soundPanel').hidden) document.getElementById('soundMore').click(); })()"
s2 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.on]")
expect(s2 == [True, 'running', True], f'the live context is untouched by the offline render {s2}')
tm = await page.evaluate("(() => { const a = window.__samon.sound.timers; window.__samon.sound.piece('capstone'); return [a, window.__samon.sound.timers]; })()")
expect(tm[1] == tm[0] + 1, f'after a world\'s bell a piece is put down to follow {tm}')
await page.evaluate(f"window.__samon.sound.play(0, {PIECES[0]['dur'] - 12})"); await page.wait_for_function("window.__samon.sound.playing !== null", timeout=5000)
await page.wait_for_timeout(4000); lv = await page.evaluate("window.__samon.sound.level()")
expect(lv > 0.002, f'the recording is heard: the output meter reads {lv:.4f} while it plays (silence would mean the media source is muted)')
await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150)
cr = await page.evaluate("[document.getElementById('soundCredits').textContent, [...document.querySelectorAll('#soundCredits a')].map(a => [a.textContent, a.href])]")
expect(f"Shakuhachi, now and then: {PIECES[0]['title']}, {PIECES[0]['player']} ({PIECES[0]['license']})." in cr[0] and cr[1] == [[PIECES[0]['title'], PIECES[0]['source']]],
f'the scroll credits the recording, with its source {cr}')
await page.wait_for_function("window.__samon.sound.playing === null", timeout=20000)
expect(True, 'the piece plays to its end and is let go')
await page.evaluate(OPEN_PANEL); await page.click('#soundToggle'); await page.wait_for_timeout(150)
s3 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]")
expect(s3 == [False, 'suspended', 0, 'Sound off', '0'], f'Sound off suspends the context, clears the loops, and is remembered {s3}')
await page.reload(); await page.wait_for_timeout(300); await quiet(page); await page.click('#garden'); await page.wait_for_timeout(200)
s4 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, document.getElementById('soundToggle').textContent]")
expect(s4 == [False, False, 'Sound off'], f'after a reload sound stays off, and a touch makes no context {s4}')
noslider = await page.evaluate("document.getElementById('volRow').hidden")
await page.evaluate(OPEN_PANEL); await page.click('#soundToggle'); await page.wait_for_timeout(200)
s5 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundVol').value, window.__samon.sound.vol]")
expect(s5[:2] == [True, 'running'] and s5[2] in (6, 7) and s5[3:] == ['30', 0.3] and noslider, f'Sound on starts it again, with its loops (drip, pipe, wind, gusts, birds, crickets) and the remembered volume; no slider while off {s5}')
# the mute button and the M key; what the page can tell of the browser's own silence
mb = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), b.title]; })()")
await page.click('#mute'); await page.wait_for_timeout(120)
mb2 = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), window.__samon.sound.on, window.__samon.sound.state, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]; })()")
await page.keyboard.press('m'); await page.wait_for_timeout(200)
mb3 = await page.evaluate("[document.getElementById('mute').className, window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers > 0]")
expect(mb == ['mute', 'false', 'Mute', 'Mute (M)'] and mb2 == ['mute off', 'true', 'Unmute', False, 'suspended', 'Sound off', '0'] and mb3 == ['mute', True, 'running', True],
f'the speaker in the header mutes and unmutes, as does M, in step with the list\'s switch and the saved setting {mb} {mb2} {mb3}')
bl = await page.evaluate("(() => { navigator.getAutoplayPolicy = () => 'disallowed'; window.__samon.sound.renderMute(); const b = document.getElementById('mute'); const r = [window.__samon.sound.blocked, b.className, b.getAttribute('aria-label')]; delete navigator.getAutoplayPolicy; window.__samon.sound.renderMute(); return r.concat([b.className]); })()")
expect(bl == [True, 'mute blocked', 'Sound is blocked by the browser', 'mute'], f'when the browser says it will not play (Firefox\'s autoplay policy), the speaker says so in red, and recovers {bl}')
aud = await page.evaluate("[typeof navigator.audioSession, typeof navigator.mediaSession, !!document.getElementById('muteHint') || localStorage.getItem('samon-sound-hint') === '1']")
expect(aud[2], f'the first start of sound shows a one-time word beside the speaker (audioSession: {aud[0]}, mediaSession: {aud[1]})')
await ctx.close()
# 14. sharing: a raking is a code in the URL's hash (the walk as a number in the mixed radix of its legal moves), shown
# but not credited to whoever opens it; a player's whole progress is another code, offered for taking over; the share
# text draws the raking in box-drawing characters
ctx = await b.new_context(viewport={'width': 500, 'height': 900}, device_scale_factor=2, color_scheme='light', permissions=['clipboard-read', 'clipboard-write'])
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
lens = []; bits = []; bad = []
for i in (range(len(G)) if not os.environ.get('SAMON_QUICK') else range(0, len(G), 7)):
g = G[i]; await page.evaluate(f"window.__samon.load({i})")
code = await page.evaluate("(w) => window.__samon.share.code(w)", g['intended'])
w = await page.evaluate("(w) => window.__samon.share.walk(w)", g['intended'])
back = await page.evaluate("(a) => window.__samon.share.unwalk(a[0], a[1])", [w[0], len(g['intended']) - 1])
h = await page.evaluate("(c) => { const h = window.__samon.share.read(c); return [h.i, h.grey, h.n.toString()]; }", code)
if not (code and back == g['intended'] and h == [i, False, w[0]]): bad.append(g['name'])
lens.append(len(code)); bits.append(w[1])
expect(not bad and max(lens) <= 18, f'every intended raking round-trips through its code: {len(lens)} gardens, {min(lens)}–{max(lens)} characters (mean {sum(lens) / len(lens):.1f}); the walks take {sum(bits) / len(bits):.1f} bits on average, against {sum(2 * (len(g["intended"]) - 1) for g in G) / len(G):.0f} at two bits a step {bad}')
lay = await page.evaluate("window.__samon.share.layouts()")
allh = lay['campaign'] + lay['daily']
expect(len(set(allh)) == len(allh) == len(G) + 366 and all(0 <= h < 2 ** 32 for h in allh), f'every garden, campaign or daily, has its own 32-bit layout hash ({len(set(allh))} of {len(allh)} distinct)')
legal = await page.evaluate("(() => { window.__samon.load(0); return window.__samon.share.walk([[2, 0], [2, 1], [1, 1]]); })()")
expect(legal is None, f'a walk that breaks a rule (onto a stone) has no code {legal}')
grid = await page.evaluate("(w) => window.__samon.share.text(w, 'red')", G[0]['intended'])
rows = grid.split('\n')
expect(rows[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and len(rows) == len(G[0]['rows']) + 3 and all(len(r) == len(G[0]['rows'][0]) + 2 for r in rows[1:])
and rows[1] == '┏━━━━┓' and rows[2] == '┃╶──┐┃' and rows[3] == '┃███│┃' and rows[4] == '┃┌──┘┃' and rows[-1] == '┗┷━━━┛', f'the share text: a heading, then the garden in box-drawing characters, the stroke in light lines through the gate in the heavy wall, stones as blocks\n{grid}')
i2 = next(i for i, g in enumerate(G) if g['end'] and g['end_side'] == 'E')
await page.evaluate(f"window.__samon.load({i2})")
grid2 = await page.evaluate("(w) => window.__samon.share.grid(w)", G[i2]['intended'])
r2 = grid2.split('\n')
expect(r2[G[i2]['end'][0] + 1].endswith('┨') and sum(1 for ch in grid2 if ch in '┨┠┯┷') == 2, f'a side gate is a second gap in the wall, where the stroke begins\n{grid2}')
# #r=: the raking is shown, with its seal, but recorded for nobody; Rake it yourself (or any tap) gives the viewer the garden fresh
gi = 5; g5 = G[gi]
await page.evaluate(f"window.__samon.load({gi})")
code = await page.evaluate("(w) => window.__samon.share.code(w)", g5['intended'])
await page.evaluate("localStorage.clear()")
await page.goto('about:blank'); await page.goto(URL + '#r=' + code); await page.wait_for_timeout(600); await quiet(page)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, window.__samon.turnsOf(), document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), document.getElementById('seal').hidden, document.getElementById('next').hidden, localStorage.getItem('samon-progress'), !!window.__samon.share.shared, document.getElementById('undo').disabled]")
expect(st[:4] == ['done', g5['name'], len(g5['intended']), g5['par']] and st[4] == f"[A raking shared with you: {g5['par']} turns, par {g5['par']}, the abbot's seal. Rake it yourself from the gate: a tap smooths the sand.]"
and st[5] == ['Rake it yourself', 'Share it on'] and not st[6] and st[7] and st[8] is None and st[9] and not st[10], f'a link to a raking opens that garden raked, with the seal, credited to nobody {st[:8]} {st[8:]}')
pts = await centers(page, [g5['intended'][3]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(300)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('status').textContent, location.hash, !!window.__samon.share.shared, document.getElementById('restore').hidden, document.getElementById('seal').hidden]")
expect(st == ['draw', g5['name'], 0, '[Now you: drag the monk in from the gate.]', '', False, True, True], f'a tap on the shared raking smooths it away: the garden is the viewer\'s, from the gate, with nothing to Restore, and the hash is gone {st}')
await page.goto(URL + '#r=' + code); await page.wait_for_timeout(400) # (a hash change on the open page shows it too)
st = await page.evaluate("[window.__samon.mode, !!window.__samon.share.shared]")
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300)
st2 = await page.evaluate("[window.__samon.mode, window.__samon.path.length, !!window.__samon.share.shared, location.hash]")
expect(st == ['done', True] and st2 == ['draw', 0, False, ''], f'the same by Rake it yourself, after the link is followed on the open page {st} {st2}')
gcode = await page.evaluate("(w) => window.__samon.share.code(w, true)", g5['intended'])
await page.goto(URL + '#r=' + gcode); await page.wait_for_timeout(400)
st = await page.evaluate("[document.getElementById('seal').className, document.getElementById('status').textContent.includes('his seal in grey')]")
expect(st == ['seal grey', True], f'a raking made with the abbot\'s help shows his grey seal {st}')
# a daily garden's raking: of another day, the viewer is sent on to today's; of today, raked at par, it is shown and then today's is theirs to rake
DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json'))
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200)
day = DAILY[276 % len(DAILY)]
dcode = await page.evaluate("(w) => window.__samon.share.code(w)", day['intended'])
await page.goto('about:blank'); await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(600); await quiet(page)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('gname').textContent, document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.daily.key]")
expect(st[:3] == ['done', 'daily:2026-10-04', '4 October 2026'] and 'of the daily garden of 4 October 2026' in st[3] and "Today's garden is another" in st[3] and st[4][0] == "Today's garden" and st[5] == '2026-10-04',
f'a shared raking of an earlier day\'s garden is shown under its date, and offers today\'s instead {st}')
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, location.hash, window.__samon.daily.done]")
expect(st[0] == 'draw' and st[1].startswith('daily:') and st[1] != 'daily:2026-10-04' and st[2] == 0 and st[3] == '' and not st[4], f'Today\'s garden leaves it for today\'s, unraked {st}')
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(400)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('status').textContent.includes('Rake it yourself'), [...document.querySelectorAll('#actions button')].map(b => b.textContent)[0]]")
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300)
st2 = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('rakeDay').hidden]")
expect(st == ['done', 'daily:2026-10-04', True, 'Rake it yourself'] and st2 == ['draw', 'daily:2026-10-04', 0, True], f'the same day\'s raking, shared, is shown and then left for the viewer to rake {st} {st2}')
# the share text and link: the share action after a raking, to the clipboard where there is no share sheet
await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
acts = await page.evaluate("[...document.querySelectorAll('#actions button')].map(b => b.textContent)")
await page.click('#actions button:has-text("Share this raking")'); await page.wait_for_timeout(400)
clip = await page.evaluate("navigator.clipboard.readText()")
st = await page.evaluate("document.getElementById('status').textContent")
cl = clip.split('\n')
expect(acts == ['Share this raking'] and cl[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and cl[1:-1] == grid.split('\n')[1:] and cl[-1].startswith(URL + '#r=') and st.startswith('[Copied: the raking drawn in lines, with a link that opens it'),
f'after a raking, Share this raking copies the heading, the drawing and a link that opens the raking {cl[0]!r} {cl[-1][-30:]!r} {st!r}')
await page.goto('about:blank'); await page.goto(cl[-1]); await page.wait_for_timeout(600)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, !!window.__samon.share.shared, window.__samon.turnsOf()]")
expect(st == ['done', G[0]['name'], True, G[0]['par']], f'and that link shows it {st}')
# #p=: progress as a code; the link offers it, Take it over joins it with what is here (whichever is better stands)
await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
for i in (0, 1, 2):
await page.evaluate(f"window.__samon.load({i})"); await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200)
await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(300); await page.click('#rakeDay')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000)
await page.evaluate("window.__samon.load(3)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[3]['intended'][:4]); await page.wait_for_timeout(100)
await page.evaluate("window.__samon.askAbbot()"); await page.wait_for_timeout(500) # (help on the unfinished walk travels with it; the save is debounced)
pcode = await page.evaluate("window.__samon.share.progress()")
mine = json.loads(await page.evaluate("localStorage.getItem('samon-progress')"))
expect(len(pcode) < 120 and mine[G[3]['name']].get('helped') and len(mine[G[3]['name']]['draft']) == 4, f'the whole progress is a code of {len(pcode)} characters {pcode}')
await page.evaluate("localStorage.clear()"); await page.goto('about:blank')
await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
await page.evaluate(f"window.__samon.load(1)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[1]['intended'][:3]); await page.wait_for_timeout(400) # (something of its own here: an unfinished walk on garden 2)
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
await page.goto(URL + '#p=' + pcode); await page.wait_for_timeout(500)
st = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), localStorage.getItem('samon-progress')]")
expect(st[0] == "[This link carries a player's progress: 3 of 148 gardens raked, 3 sealed, 1 daily garden, and an unfinished walk. Take it over here? It joins what is here, and whichever is better stands.]" and st[1] == ['Take it over', 'Leave it'] and G[0]['name'] not in (st[2] or ''),
f'a progress link is offered, not taken, with what it holds {st[:2]}')
tt = await page.evaluate("[...document.querySelectorAll('.veranda button, .assists button, .assists .t, #nav button, #nav .more, #nav .lock, #actions button, #mute, #soundMore, #soundPanel button, #soundPanel input, #orig, .count span, #where, .brand, #seal, .help summary')].filter(e => !e.title).map(e => e.id || e.className || e.textContent)")
expect(not tt, f'every control, count and mark has a tooltip (gwern.net wants one wherever a hover could want a word more) {tt}')
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(400)
got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')"))
st = await page.evaluate("[document.getElementById('status').textContent, location.hash, window.__samon.G.name, window.__samon.path.length, window.__samon.helped, [...document.querySelectorAll('#nav .pip.sealed')].length, window.__samon.daily.done, window.__samon.daily.turns, window.__samon.daily.streak]")
expect(all(got[G[i]['name']] == mine[G[i]['name']] for i in (0, 2)) and got[G[1]['name']]['done'] and got[G[1]['name']]['sealed'] and len(got[G[1]['name']]['draft']) == 3
and got[G[3]['name']]['draft'] == mine[G[3]['name']]['draft'] and got[G[3]['name']].get('helped') and got['daily:2026-10-04']['path'] == mine['daily:2026-10-04']['path'] and got['daily:2026-10-04']['turns'] == day['par'] and got['daily:2026-10-04']['kind'] == 'red'
and st[0].startswith('[Taken over: 3 of 148 gardens raked') and st[1] == '' and st[2] == G[3]['name'] and st[3] == 4 and st[4] and st[5] == 3 and st[6] and st[7] == day['par'] and st[8] == 1,
f'Take it over: the rakings, the seals, the day\'s record with its walk, and the unfinished walk with its help arrive, and the walk of its own here is kept {st}')
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300)
st = await page.evaluate("[window.__samon.mode, window.__samon.path.length, document.getElementById('status').textContent.includes(\"Today's record\")]")
expect(st == ['done', len(day['intended']), True], f'the carried day\'s raking shows as the record {st}')
await page.click('#actions button:has-text("Share today\'s score")'); await page.wait_for_timeout(400)
cl = (await page.evaluate("navigator.clipboard.readText()")).split('\n')
shape = await page.evaluate("window.__samon.share.grid([])")
expect(cl[0] == f"Samon · daily garden 2026-10-04 · {day['par']} turns, par {day['par']} · 静 · streak 1" and cl[1:-1] == shape.split('\n') and cl[-1] == URL + '#daily'
and not any(ch in shape for ch in '─│┌┐└┘╴╵╶╷') and shape.count('░') == sum(r.count('.') for r in day['rows']),
f'the daily garden shares the score and the garden\'s shape, without the stroke, and a link to today\'s garden {cl[0]!r} {cl[-1][-8:]!r}')
await page.evaluate("window.__samon.load(0)"); await page.goto(URL + '#daily'); await page.wait_for_timeout(400)
st = await page.evaluate("[window.__samon.G.name, location.hash]")
expect(st == ['daily:2026-10-04', ''], f'#daily opens today\'s garden {st}')
await page.click('#carry'); await page.wait_for_timeout(400)
cl = await page.evaluate("navigator.clipboard.readText()"); st = await page.evaluate("document.getElementById('status').textContent")
expect(cl.startswith(URL + '#p=') and st.startswith('[Copied a link that carries your progress'), f'Carry my progress copies such a link {cl[:60]!r} {st!r}')
await page.goto('about:blank'); await page.goto(URL + '#r=AAAA'); await page.wait_for_timeout(500)
st = await page.evaluate("[document.getElementById('status').textContent, document.getElementById('status').className, location.hash, window.__samon.mode]")
expect(st == ["[The link's code can't be read: it was made by another edition of the game.]", 'status warn', '', 'draw'], f'a code the page can\'t read is said so, and dropped {st}')
await page.goto(URL + '#p=not a code'); await page.wait_for_timeout(300)
st = await page.evaluate("[document.getElementById('status').textContent, location.hash]")
expect(st == ["[The link's code can't be read: it is not a Samon code.]", ''], f'so is a string that is no code at all {st}')
# durability (Astra's points): a garden is named by a 32-bit layout hash, not its place, so a code survives a reordering of the
# campaign and a changed garden is refused, never mistaken for the one meant
B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
await page.evaluate("window.__samon.load(5)")
code5 = await page.evaluate("(w) => window.__samon.share.code(w)", G[5]['intended'])
bits = ''.join(format(B64.index(ch), '06b') for ch in code5)
bent = bits[:5] + ('1' if bits[5] == '0' else '0') + bits[6:] # one bit of the layout hash
bent = ''.join(B64[int(bent[i:i + 6], 2)] for i in range(0, len(bent), 6))
await page.goto(URL + '#r=' + bent); await page.wait_for_timeout(400)
st = await page.evaluate("[document.getElementById('status').textContent, window.__samon.mode, location.hash]")
expect(st == ["[The link's code can't be read: its garden is not in this edition, or has changed since.]", 'draw', ''], f'a code whose garden hash matches no garden is refused, not shown over another garden {st}')
await page.evaluate("(w) => window.__samon.setPath(w)", G[5]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
pcode5 = await page.evaluate("window.__samon.share.progress()")
await page.evaluate("window.__samon.share.swap(5, 9); window.__samon.load(0)") # the campaign reordered under the links
await page.goto(URL + '#r=' + code5); await page.wait_for_timeout(400)
st = await page.evaluate("[window.__samon.G.name, window.__samon.mode, !!window.__samon.share.shared, window.__samon.idx, document.querySelector('#nav .pip[aria-current=\"true\"]').getAttribute('aria-label')]")
expect(st == [G[5]['name'], 'done', True, 9, G[5]['name']], f'after the campaign is reordered, a raking link still opens the garden it was made in, now in its new place {st}')
await page.evaluate("localStorage.clear()")
await page.goto(URL + '#p=' + pcode5); await page.wait_for_timeout(400)
await page.click('#actions button:has-text("Take it over")'); await page.wait_for_timeout(400)
got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')"))
expect(got.get(G[5]['name'], {}).get('sealed') and G[9]['name'] not in got, f'and carried progress lands on the garden it was earned in, not on the one now at its index {sorted(got)}')
await page.evaluate("window.__samon.share.swap(5, 9)")
await ctx.close()
await b.close()
real = [e for e in errors if 'ERR_TUNNEL' not in e and 'Failed to load resource' not in e]
expect(not real, f'no page errors {real}')
print('FAILS:', fails)
asyncio.run(main())
proto/test/smoke.py
import asyncio, json
from playwright.async_api import async_playwright
URL = 'file:///home/claude/zen/proto/test/index.html'
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
page = await (await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2)).new_page()
errs = []
page.on('console', lambda m: errs.append(m.text) if m.type == 'error' else None)
page.on('pageerror', lambda e: errs.append('PAGEERROR ' + str(e)))
await page.goto(URL); await page.wait_for_timeout(300)
n = await page.evaluate("GARDENS.length")
for i in range(n):
r = await page.evaluate(f"(() => {{ window.__samon.load({i}); const a = window.__samon.analyze([window.__samon.G.gateCell]); return [window.__samon.G.name, a.state, window.__samon.G.parK.length]; }})()")
print(r)
await page.evaluate("window.__samon.enterPractice()")
print("practice", await page.evaluate("window.__samon.practice()"))
print("errors:", [e for e in errs if 'ERR_TUNNEL' not in e and 'Failed to load resource' not in e])
await b.close()
asyncio.run(main())
proto/test/thumbnail.py — The social-media thumbnail (1200×630): a garden raked at par, with its seal, beside the title, in the page's own style.
"""The social-media thumbnail (1200×630): a garden raked at par, with its seal, beside the title, in the page's own style.
Usage: python3 proto/test/thumbnail.py [garden index] -> deliver/2026-10-05-gwern-samon-thumbnail.png (and a preview of the
candidates in the scratchpad when run with 'pick').
"""
import asyncio, base64, os, sys
from playwright.async_api import async_playwright
URL = 'file:///home/claude/zen/proto/test/index.html'
OUT = '/home/claude/zen/deliver/2026-10-05-gwern-samon-thumbnail.png'
PICK = sys.argv[1] if len(sys.argv) > 1 else '25'
COMPOSE = """<!doctype html><html lang="en"><head><meta charset="utf-8"><style>
:root { --fg: #000; --bg: #fff; --muted: #767676; --seal: #c23b22; }
body { margin: 0; width: 1200px; height: 630px; background: var(--bg); color: var(--fg); font-family: "Source Serif 4", "Apple Garamond", Baskerville, "Libre Baskerville", "Hiragino Mincho ProN", "Yu Mincho", "Noto Serif CJK JP", "Times New Roman", Times, serif; display: grid; grid-template-columns: 630px 1fr; }
.garden { padding: 36px 0 36px 44px; display: grid; align-items: center; }
.garden img { width: 100%; display: block; }
.text { padding: 0 56px 0 48px; display: grid; align-content: center; gap: 18px; }
h1 { margin: 0; font-size: 92px; line-height: 1; font-weight: 700; letter-spacing: 0.01em; }
h1 span { display: block; font-size: 34px; font-weight: 400; color: var(--muted); letter-spacing: 0.22em; margin-top: 14px; }
p { margin: 0; font-size: 27px; line-height: 1.35; }
p.rule { color: var(--fg); }
p.site { font-family: "Source Sans 3", "Source Sans Pro", "Lucida Sans Unicode", Helvetica, sans-serif; font-size: 20px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); margin-top: 10px; }
.mark { display: inline-block; width: 16px; height: 16px; background: var(--seal); vertical-align: -1px; margin-right: 10px; transform: rotate(-6deg); }
</style></head><body>
<div class="garden"><img src="data:image/png;base64,__IMG__" alt=""></div>
<div class="text">
<h1>Samon<span>砂紋</span></h1>
<p class="rule">A Zen-garden raking puzzle: walk the monk in from the gate, cover every cell of sand in one stroke, and he rakes his way back out.</p>
<p class="site"><span class="mark"></span>gwern.net · design gwern.net · design & playable prototypeamp; prototype</p>
</div>
</body></html>"""
async def shot(page, gi, path):
await page.evaluate(f"window.__samon.load({gi})"); await page.wait_for_timeout(150)
G = await page.evaluate("window.__samon.G")
await page.evaluate("(w) => window.__samon.setPath(w)", G['intended'])
await page.wait_for_function("window.__samon.mode === 'done'", timeout=20000); await page.wait_for_timeout(500)
r = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()")
await page.screenshot(path=path, clip={'x': r[0], 'y': r[1], 'width': r[2], 'height': r[3]})
return G['name']
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
ctx = await b.new_context(viewport={'width': 560, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page()
await page.goto(URL); await page.wait_for_timeout(300)
await page.evaluate("localStorage.setItem('samon-open-all', '1'); localStorage.setItem('samon-started', '1'); localStorage.setItem('samon-sound-hint', '1'); localStorage.removeItem('samon-assists')")
await page.reload(); await page.wait_for_timeout(400)
await page.evaluate("window.__samon.poemTiming = {delay: 3600000}")
scratch = '/tmp/claude-0/-home-claude/1d7d7911-7ec3-59eb-91d4-be08299e88f7/scratchpad/'
if PICK == 'pick':
for gi in (22, 23, 25, 26, 28, 47, 49):
name = await shot(page, gi, f'{scratch}thumb-cand-{gi}.png'); print(gi, name)
await b.close(); return
gi = int(PICK)
name = await shot(page, gi, scratch + 'thumb-garden.png')
img = base64.b64encode(open(scratch + 'thumb-garden.png', 'rb').read()).decode()
comp = await (await b.new_context(viewport={'width': 1200, 'height': 630}, device_scale_factor=1)).new_page()
await comp.set_content(COMPOSE.replace('__IMG__', img)); await comp.wait_for_timeout(300)
await comp.screenshot(path=OUT, clip={'x': 0, 'y': 0, 'width': 1200, 'height': 630})
print('wrote', OUT, 'from', name)
await b.close()
asyncio.run(main())
proto/test/wrap.py — Test page = the standalone build, with no extra base styles (so the test can't mask a missing rule in the page).
"""Test page = the standalone build, with no extra base styles (so the test can't mask a missing rule in the page)."""
import shutil
shutil.copyfile('/home/claude/zen/proto/samon.standalone.html', '/home/claude/zen/proto/test/index.html')
This single-file edition and its test (standalone/)
standalone/build_standalone.py — Build one fully self-contained HTML file: the Samon design document (from samon.md, via Pandoc) with the playable
"""Build one fully self-contained HTML file: the Samon design document (from samon.md, via Pandoc) with the playable
prototype embedded in the same page, and an appendix with every file of the code bundle (code shown inline; everything
in an embedded .zip). No external resources: figures are embedded SVG data URIs, no web fonts, no scripts or styles
from elsewhere; only ordinary hyperlinks point outside.
Usage: python3 build_standalone.py -> /home/claude/zen/standalone/2026-10-03-gwern-samon.html
"""
import ast
import base64
import hashlib
import html
import io
import json
import os
import re
import subprocess
import sys
import zipfile
from bs4 import BeautifulSoup
ROOT = "/home/claude/zen"
OUT = f"{ROOT}/standalone/2026-10-03-gwern-samon.html"
FIG = f"{ROOT}/fig/final"
def must_replace(s, old, new, count=1):
n = s.count(old)
if n != count:
sys.exit(f"expected {count} occurrence(s) of {old[:70]!r}, found {n}")
return s.replace(old, new)
# ---------------------------------------------------------------- 1. the document: Gwerndown -> Pandoc Markdown
md = open(f"{ROOT}/samon.md").read()
m = re.match(r"^---\n(.*?)\n\.\.\.\n", md, re.S)
if not m:
sys.exit("no YAML header")
meta = {}
for line in m.group(1).splitlines():
k, _, v = line.partition(":")
meta[k.strip()] = v.strip().strip('"')
body = md[m.end():]
body = re.sub(r"<!--.*?-->\n?", "", body, flags=re.S) # handoff notes and TODOs are for the source, not the page
def wiki(mo): # gwern.net's [text](!W) and [text](!W "Article")
text, title = mo.group(1), mo.group(2)
article = title or re.sub(r"[_*]", "", text)
url = "https://en.wikipedia.org/wiki/" + article.replace(" ", "_")
return f'[{text}]({url} "{article} (Wikipedia)")'
body, nwiki = re.subn(r'\[((?:[^\[\]]|\[[^\]]*\])*)\]\(!W(?:\s+"([^"]*)")?\)', wiki, body)
# point the document's mentions of the prototype at the copy in this page
body = must_replace(body, "A playable prototype implements most of it, with the whole campaign of 148 gardens and their poems.",
"A playable prototype implements most of it, with the whole campaign of 148 gardens and their poems: [play it below](#play).")
body = must_replace(body, "[Prototype.]{.margin-note} A one-page HTML prototype, included with the code, implements",
"[Prototype.]{.margin-note} The one-page prototype [at the top of this page](#play) implements")
body = must_replace(body, "Everything is reproducible from the attached code.",
"Everything is reproducible from the code, which is in the [appendix](#code).")
html_doc = subprocess.run(["pandoc", "-f", "markdown+smart", "-t", "html5", "--section-divs", "--wrap=none"],
input=body, capture_output=True, text=True, check=True).stdout
soup = BeautifulSoup(html_doc, "html.parser")
# figures: embed the SVG renders as data URIs
nimg = 0
for img in soup.find_all("img"):
src = img.get("src", "")
mo = re.match(r"^/doc/design/(.+)\.png$", src)
if not mo:
sys.exit(f"unexpected image {src}")
svg = open(f"{FIG}/{mo.group(1)}.svg", "rb").read()
img["src"] = "data:image/svg+xml;base64," + base64.b64encode(svg).decode()
img["decoding"] = "async"
if not img.get("alt"):
img["alt"] = ""
nimg += 1
# site-relative links -> gwern.net
for a in soup.find_all("a", href=True):
if a["href"].startswith("/"):
a["href"] = "https://gwern.net" + a["href"]
# collapsed sections -> <details>
for sec in soup.find_all("section", class_="collapse"):
head = sec.find(re.compile(r"^h[1-6]$"))
det = soup.new_tag("details", attrs={"class": "collapse " + " ".join(c for c in sec.get("class", []) if c.startswith("level")), "id": sec.get("id", "")})
summ = soup.new_tag("summary")
head.extract()
if head.get("class"):
del head["class"]
head["id"] = (sec.get("id") or "") + "-h"
summ.append(head)
det.append(summ)
for child in list(sec.children):
det.append(child.extract())
sec.replace_with(det)
# a footnote's reference shows the footnote itself on hover (gwern.net shows it in a popup); a collapsed section's summary says what it does
for a in soup.find_all("a", class_="footnote-ref", href=True):
fn = soup.find(id=a["href"].lstrip("#"))
if fn:
a["title"] = re.sub(r"\s*↩︎?\s*$", "", " ".join(fn.get_text(" ").split()))
for summ in soup.find_all("summary"):
summ["title"] = "Click to show or hide this section"
# tables scroll sideways on narrow screens
for t in soup.find_all("table"):
wrap = soup.new_tag("div", attrs={"class": "table-scroll"})
t.wrap(wrap)
# the abstract and what follows it
abstract = soup.find("div", class_="abstract")
abstract.extract()
first_p = soup.find("p")
first_p["class"] = first_p.get("class", []) + ["dropcap"]
# table of contents from the level-1 and level-2 headings
toc_items = []
for h in soup.find_all(["h1", "h2"]):
hid = h.get("id") or (h.find_parent(["section", "details"]) or {}).get("id")
if h.get("id", "").endswith("-h"):
hid = h["id"][:-2]
if not hid:
continue
toc_items.append((h.name, hid, h.get_text(" ", strip=True)))
APPENDIX_TITLE = "Appendix: Code and Data"
toc_items.append(("h1", "code", APPENDIX_TITLE)) # built in part 4
toc = ['<nav class="toc" aria-label="Contents"><div class="toc-title">Contents</div><ol>',
'<li><a href="#play">Play the prototype</a></li>']
open_sub = False
for lvl, hid, text in toc_items:
if lvl == "h1":
if open_sub:
toc.append("</ol></li>")
open_sub = False
toc.append(f'<li><a href="#{hid}">{html.escape(text)}</a>')
toc.append("<ol>")
open_sub = True
else:
toc.append(f'<li><a href="#{hid}">{html.escape(text)}</a></li>')
if open_sub:
toc.append("</ol></li>")
toc.append("</ol></nav>")
toc_html = re.sub(r"<li><a ([^>]*)>([^<]*)</a><ol></ol></li>", r"<li><a \1>\2</a></li>", "".join(toc))
# ---------------------------------------------------------------- 2. the game, from the prototype template
tpl = open(f"{ROOT}/proto/samon.template.html").read()
gardens = json.load(open(f"{ROOT}/out/proto_gardens.json"))
sys.path.insert(0, f"{ROOT}/src")
from poems import attach
from sound_pieces import pieces_html
practice_poem = attach(gardens) # each garden's poem, and the practice garden's (poems/samon-poems.json)
game_css = tpl[tpl.index("<style>") + 7: tpl.index("</style>")]
game_markup = tpl[tpl.index('<div class="wrap">'): tpl.index("<script>")].strip()
game_markup = must_replace(game_markup, "__PIECES__", pieces_html()) # the recordings (out/sound_pieces.json; sound/make_sound_bundle.py)
game_js = tpl[tpl.index("<script>") + 8: tpl.rindex("</script>")]
game_js = must_replace(game_js, "__GARDENS__", json.dumps(gardens, separators=(",", ":"), ensure_ascii=False))
game_js = must_replace(game_js, "__PRACTICE_POEM__", json.dumps(practice_poem, separators=(",", ":"), ensure_ascii=False))
DAILY_PATH = f"{ROOT}/out/daily_gardens.json"
daily = json.load(open(DAILY_PATH)) if os.path.exists(DAILY_PATH) else []
if not daily:
print("warning: no daily gardens (out/daily_gardens.json missing)")
game_js = must_replace(game_js, "__DAILY__", json.dumps(daily, separators=(",", ":"), ensure_ascii=False))
game_js = must_replace(game_js, "const VIEW_EL = document.body;", "const VIEW_EL = document.getElementById(\"play\");")
if "document.body.classList" in game_js:
sys.exit("the game puts a view class on document.body; the single-file edition scopes its styles to #play, so use VIEW_EL")
game_js = must_replace(game_js, "window.claude?.hot?.snapshot?.(() => ({ idx, view, path: mode === \"draw\" ? path : [] }));\n", "")
game_js = must_replace(game_js, "window.claude?.hot?.ready ? window.claude.hot.ready(start) : start(window.claude?.hot?.data ?? {});", "start({});")
if "</script" in game_js.lower():
sys.exit("game script contains a closing script tag")
def prefix_css(css, pre="#play"):
"""Scope the prototype's stylesheet to the game's container (it was written to own the whole page)."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
out, i, n = [], 0, len(css)
while i < n:
j = css.find("{", i)
if j < 0:
break
sel = css[i:j].strip()
depth, k = 0, j
while k < n:
depth += css[k] == "{"
depth -= css[k] == "}"
if depth == 0:
break
k += 1
block = css[j + 1:k]
i = k + 1
if sel.startswith("@media"):
out.append(f"{sel} {{\n{prefix_css(block, pre)}\n}}")
elif sel.startswith("@keyframes"):
out.append(f"{sel} {{{block}}}")
elif sel in ("html, body", "body"):
continue # the page styles its own body
else:
parts = []
for s in (x.strip() for x in sel.split(",")):
if s.startswith(":root") or s.startswith("[hidden]"):
parts.append(s)
elif s.startswith("body"):
parts.append(pre + s[4:])
else:
parts.append(f"{pre} {s}")
out.append(f"{', '.join(parts)} {{{block}}}")
return "\n".join(out)
game_css_scoped = prefix_css(game_css)
# ---------------------------------------------------------------- 3. the appendix: every file behind the page, readable
# here, and all of them as one .zip (embedded as base64; a .zip because that is a format the claude.ai viewer can save)
BUNDLE = f"{ROOT}/deliver/samon-code" if os.path.isdir(f"{ROOT}/deliver/samon-code") else ROOT
ZIP_NAME = "2026-10-03-gwern-samon-code.zip"
SHOWN_EXT = {".py", ".c", ".md", ".html", ".sh", ".log"} # code and logs are shown; data, figures and caches are only in the .zip
SHOWN_ALSO = {"poems/samon-poems.json", "sound/tracks.json"} # (the poems and the recordings' credits: editions rather than machine output)
GROUPS = [("", "The guide and the document"), ("src/", "Solvers, experiments, the level generator, and the document's checks"),
("proto/", "The prototype and its tests"), ("standalone/", "This single-file edition and its test"),
("poems/", "The poems"), ("sound/", "The sounds: the recordings' credits and their encoder (the synthesis is in the prototype)"),
("out/", "Data: levels, experiment results, logs, and the campaign cache"),
("fig/", "Figures (shown in the document as SVG; PNG renders for the web)")]
def generated(p): # this page, the prototype pages build.py writes, and local build or test output
return (p in {"standalone/2026-10-03-gwern-samon.html", "proto/samon.html", "proto/samon.standalone.html", "src/zgsolve"}
or "__pycache__" in p.split("/") or p.startswith("standalone/shots/")
or (p.startswith("proto/test/") and not p.endswith(".py")))
files = sorted(os.path.relpath(os.path.join(d, f), BUNDLE).replace(os.sep, "/") for d, _, fs in os.walk(BUNDLE) for f in fs)
files = [p for p in files if not generated(p)]
for want in ("README.md", "samon.md", "src/zgsolve.c", "src/zg.py", "src/zcp.py", "src/grader.py", "src/verify_doc.py",
"proto/samon.template.html", "proto/test/run.py", "standalone/build_standalone.py", "poems/samon-poems.json"):
if want not in files:
sys.exit(f"the code bundle lacks {want}")
modified = tuple(int(x) for x in meta["modified"].split("-")) + (0, 0, 0)
zbuf = io.BytesIO()
with zipfile.ZipFile(zbuf, "w") as z: # deterministic: sorted, fixed dates and modes
for p in files:
zi = zipfile.ZipInfo(f"samon-code/{p}", date_time=modified)
zi.compress_type = zipfile.ZIP_DEFLATED
zi.external_attr = 0o644 << 16
z.writestr(zi, open(f"{BUNDLE}/{p}", "rb").read(), compresslevel=9)
zip_bytes = zbuf.getvalue()
zip_sha = hashlib.sha256(zip_bytes).hexdigest()
open(f"{ROOT}/standalone/{ZIP_NAME}", "wb").write(zip_bytes) # (the test compares the page's copy with this one)
def describe(p, text):
"""A file's own one-line description: a Python module's docstring, or a C file's opening comment."""
first = ""
if p.endswith(".py"):
try:
doc = (ast.get_docstring(ast.parse(text)) or "").strip()
except SyntaxError:
doc = ""
first = doc.splitlines()[0] if doc else ""
elif p.endswith(".c"):
mo = re.match(r"\s*/\*+\s*(.*)", text)
first = mo.group(1).strip() if mo else ""
first = re.sub(r"^[\w.]+\.(py|c):\s*", "", first)
return first[:1].upper() + first[1:] if first else ""
def human(n):
return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{n / 1e3:.1f} kB" if n >= 1e3 else f"{n} bytes"
def fid(p):
return "file-" + re.sub(r"[^A-Za-z0-9]+", "-", p).strip("-").lower()
readme_html = subprocess.run(["pandoc", "-f", "markdown+smart", "-t", "html5", "--wrap=none", "--shift-heading-level-by=2", "--id-prefix=readme-"],
input=open(f"{BUNDLE}/README.md").read(), capture_output=True, text=True, check=True).stdout
readme_html = re.sub(r"<h3[^>]*>.*?</h3>\s*", "", readme_html, count=1, flags=re.S) # its title; the appendix has one
grouped = {g: [] for g, _ in GROUPS}
for p in files:
g = next((g for g, _ in GROUPS[1:] if p.startswith(g)), "")
grouped[g].append(p)
if sum(map(len, grouped.values())) != len(files):
sys.exit("a file fell outside the appendix's groups")
shown = archived = 0
parts = []
for g, title in GROUPS:
if not grouped[g]:
continue
gid = "code-" + (g.strip("/") or "guide")
parts.append(f'<h2 id="{gid}">{html.escape(title)}{f" (<code>{g}</code>)" if g else ""}</h2>')
rows = []
for p in grouped[g]:
raw = open(f"{BUNDLE}/{p}", "rb").read()
ext = os.path.splitext(p)[1]
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = None
inline = text is not None and (ext in SHOWN_EXT or p in SHOWN_ALSO)
size = human(len(raw))
if inline:
shown += 1
lines = text.count("\n") + (not text.endswith("\n"))
desc = describe(p, text)
parts.append(f'<details class="file" id="{fid(p)}" data-path="{html.escape(p)}"><summary title="Show or hide this file (shown exactly as it is in the .zip)"><code>{html.escape(p)}</code>'
f'<span class="file-meta"> · {lines:,} line{"s" if lines != 1 else ""} · {size}</span>'
f'{f"<span class=file-desc> — {html.escape(desc)}</span>" if desc else ""}</summary>'
f'<pre><code>{html.escape(text, quote=False)}</code></pre></details>')
else:
archived += 1
why = ("figure source; the figure is shown in the document" if ext == ".svg" and p.startswith("fig/")
else "image" if ext == ".png" else "binary" if text is None else "data")
rows.append(f'<li><code>{html.escape(p)}</code> <span class="file-meta">· {size} · {why}; in the .zip</span></li>')
if rows:
parts.append(f'<ul class="file-list">{"".join(rows)}</ul>')
start = "".join(f'<a href="#{fid(p)}"><code>{p}</code></a>{s}' for p, s in (
("src/zgsolve.c", " (exact search), "), ("src/zg.py", " (the rules, the solver driver and a brute-force checker), "),
("src/zcp.py", " (CP-SAT par proofs), "), ("src/grader.py", " (the deductive grader), and "),
("src/verify_doc.py", ", which re-derives every number in the document")))
appendix_html = f"""<details class="collapse level1 appendix" id="code"><summary><h1 id="code-h">{APPENDIX_TITLE}</h1></summary>
<div class="abstract-collapse">Every file behind this page: the solvers, the experiments, the level generator, the prototype's source and its tests, this edition's build script and test, the poems, and the data they wrote. The code, the logs and the poems are shown below; the data, the figures' sources and the campaign cache are listed, and everything is in the download.</div>
<p class="bundle"><a id="bundle-dl" href="#code" download="{ZIP_NAME}" title="Save the .zip carried in this page: every file behind it, as listed below"><strong>Download all {len(files)} files</strong></a> as one .zip ({human(len(zip_bytes))}; SHA-256 <code class="sha">{zip_sha}</code>).<span id="bundle-msg" role="status"></span></p>
<p>Start with {start}. The build and test commands are in the guide below. Not included: this page itself and the prototype's two pages, which <code>standalone/build_standalone.py</code> and <code>proto/build.py</code> write.</p>
<div class="readme">{readme_html}</div>
{"".join(parts)}
<script type="text/plain" class="asset" id="bundle-zip">data:application/zip;base64,{base64.b64encode(zip_bytes).decode()}</script>
</details>"""
print(f"appendix: {len(files)} files ({shown} shown, {archived} only in the .zip); .zip {len(zip_bytes):,} bytes")
DOWNLOAD_JS = r"""
(function () { // the appendix's download: the viewer's save prompt inside claude.ai, a plain download elsewhere
const a = document.getElementById("bundle-dl"), msg = document.getElementById("bundle-msg");
if (!a) return;
const name = a.getAttribute("download");
const viewer = (window.claude && typeof window.claude.use === "function") ? window.claude.use("downloads").catch(() => null) : Promise.resolve(null);
let blob = null;
async function zip() { // the .zip is a data: URL in the page, or (in a gwtar build) a member of the archive beside it: samonAsset reads either
if (!blob) blob = new Blob([await samonAsset.bytes(samonAsset.text(document.getElementById("bundle-zip")))], { type: "application/zip" });
return blob;
}
a.addEventListener("click", async ev => {
ev.preventDefault(); msg.textContent = "";
const dl = await viewer;
let z;
try { z = await zip(); } catch (e) { msg.textContent = " [The .zip could not be read from this copy of the page.]"; return; }
if (dl) {
try { await dl.save({ filename: name, data: z }); msg.textContent = " [Saved.]"; }
catch (e) { if (!e || e.code !== "declined") msg.textContent = ` [It could not be saved here (${(e && e.code) || "error"}).]`; }
return;
}
const url = URL.createObjectURL(z), t = document.createElement("a");
t.href = url; t.download = name; document.body.appendChild(t); t.click(); t.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
});
function reveal() { // a link to a file (or to anything inside a collapsed section) opens what hides it
const id = decodeURIComponent(location.hash.slice(1)), t = id && document.getElementById(id);
if (!t) return;
let opened = false;
if (t.matches("details.file") && !t.open) { t.open = true; opened = true; }
for (let el = t.parentElement; el; el = el.parentElement) if (el.tagName === "DETAILS" && !el.open) { el.open = true; opened = true; }
if (opened) t.scrollIntoView({ block: "start" });
}
window.addEventListener("hashchange", reveal); reveal();
})();
"""
# ---------------------------------------------------------------- 4. the page
PAGE_CSS = r"""
/* gwern.net's manner (gwern.net/design): black on white, greys for the secondary, square corners, hairlines, serif text
with sans apparatus. No web fonts: gwern.net's own stacks, with the system's CJK fonts for 砂紋, the Japanese and 静. */
:root {
--font-text: var(--font-display);
--plate: #ffffff;
--col: 42rem;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--font-text); font-size: 1.0625rem; line-height: 1.6; padding: 0 16px; }
a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--fg) 35%, transparent); text-underline-offset: 2px; }
a:hover { color: var(--muted); text-decoration-color: var(--muted); }
.page { max-width: var(--col); margin: 0 auto; padding: 28px 0 64px; }
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; font-family: var(--font-ui); font-size: 0.8125rem; color: var(--muted); }
.topbar .jump a { margin-right: 14px; }
.theme { display: inline-flex; border: 1px solid var(--line); overflow: hidden; }
.theme button { font: inherit; background: transparent; color: var(--muted); border: 0; border-radius: 0; padding: 4px 10px; cursor: pointer; }
.theme button:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); }
.theme button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
h1.title { font-family: var(--font-display); font-weight: 700; font-size: clamp(1.9rem, 6vw, 2.6rem); line-height: 1.15; margin: 22px 0 8px; text-wrap: balance; }
.subtitle { color: var(--muted); font-style: italic; font-size: 0.98rem; margin: 0 0 10px; }
.meta { font-family: var(--font-ui); font-size: 0.8125rem; color: var(--muted); margin-bottom: 22px; }
.abstract { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 14px 0 4px; margin: 0 0 26px; font-size: 1rem; }
.abstract p { margin: 0 0 12px; }
#play { margin: 0 auto 30px; padding: 18px 0 6px; border-bottom: 1px solid var(--line); font-family: var(--font-ui); font-size: var(--step-0); line-height: 1.55; }
#play .wrap { padding-block: 0 18px; }
.play-head { font-family: var(--font-ui); font-size: 0.8125rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); max-width: 600px; margin: 0 auto 8px; }
.toc { border: 1px solid var(--line); background: color-mix(in srgb, var(--fg) 3%, transparent); padding: 12px 16px; margin: 0 0 28px; font-family: var(--font-ui); font-size: 0.875rem; line-height: 1.5; }
.toc-title { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
.toc ol { margin: 0; padding-left: 1.3em; }
.toc ol ol { padding-left: 1.1em; color: var(--muted); }
@media (min-width: 700px) { .toc > ol { columns: 2; column-gap: 28px; } .toc > ol > li { break-inside: avoid; } }
.doc h1, .doc h2, details.collapse summary h1, details.collapse summary h2 { font-family: var(--font-display); font-weight: 700; line-height: 1.25; }
.doc h1 { font-size: 1.6rem; margin: 2.2em 0 0.6em; border-bottom: 1px solid #888; padding-bottom: 4px; }
.doc h2 { font-size: 1.25rem; margin: 1.8em 0 0.5em; }
.doc p { margin: 0 0 0.9em; position: relative; }
.doc p.dropcap::first-letter { float: left; font-family: var(--font-display); font-size: 3.3em; line-height: 0.85; padding: 0.06em 0.08em 0 0; color: var(--fg); }
.margin-note { font-family: var(--font-ui); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--muted); }
.margin-note::after { content: " "; }
@media (min-width: 1180px) {
.doc p > .margin-note:first-child, .doc li > .margin-note:first-child { position: absolute; left: -12.5rem; width: 11rem; text-align: right; top: 0.25em; line-height: 1.35; font-weight: 600; }
}
.doc ul, .doc ol { padding-left: 1.4em; }
.doc li { margin-bottom: 0.35em; }
.doc figure { margin: 1.4em 0; }
.doc figure img { display: block; width: 100%; height: auto; background: var(--plate); border: 1px solid var(--line); }
.doc figure img:not(.width-full) { max-width: 30rem; margin: 0 auto; }
.doc figcaption { font-size: 0.9rem; color: var(--muted); margin-top: 8px; line-height: 1.45; }
.table-scroll { overflow-x: auto; margin: 1.2em 0; }
.doc table { border-collapse: collapse; width: 100%; font-family: var(--font-ui); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
.doc caption { caption-side: bottom; text-align: left; color: var(--muted); font-size: 0.8rem; padding-top: 6px; font-family: var(--font-text); }
.doc th, .doc td { padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
.doc thead th { border-bottom: 2px solid var(--fg); }
.doc tbody tr:nth-child(odd) { background: color-mix(in srgb, var(--fg) 3.5%, transparent); }
details.collapse { margin: 2em 0 1em; border-top: 1px solid var(--line); }
details.collapse > summary { cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: 10px; }
details.collapse > summary::-webkit-details-marker { display: none; }
details.collapse > summary::before { content: "▸"; font-family: var(--font-ui); color: var(--muted); }
details.collapse[open] > summary::before { content: "▾"; }
details.collapse > summary h1 { font-size: 1.6rem; margin: 0.6em 0; }
details.collapse > summary h2 { font-size: 1.25rem; margin: 0.6em 0; }
details.collapse > summary::after { content: "[click to expand]"; font-family: var(--font-ui); font-size: 0.75rem; color: var(--muted); }
details.collapse[open] > summary::after { content: ""; }
.abstract-collapse { font-style: italic; color: var(--muted); }
.footnotes { font-size: 0.9rem; border-top: 1px solid var(--line); margin-top: 2.5em; padding-top: 0.5em; }
.footnotes hr { display: none; }
sup { line-height: 0; }
.colophon { font-family: var(--font-ui); font-size: 0.78rem; color: var(--muted); border-top: 1px solid var(--line); margin-top: 3em; padding-top: 10px; }
/* the appendix: code listings */
.appendix h2 { font-size: 1.15rem; }
.appendix .readme { font-size: 0.92rem; }
.appendix .readme h4 { font-family: var(--font-ui); font-size: 0.8rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin: 1.4em 0 0.4em; }
.appendix code, .appendix pre { font-family: "IBM Plex Mono", "Liberation Mono", Consolas, Menlo, Courier, monospace; }
.appendix :not(pre) > code { font-size: 0.86em; }
.appendix .sha { word-break: break-all; font-size: 0.78em; }
.appendix .bundle { font-family: var(--font-ui); font-size: 0.95rem; }
details.file { border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent); }
details.file > summary { cursor: pointer; font-family: var(--font-ui); font-size: 0.86rem; line-height: 1.45; padding: 5px 0; }
details.file > summary code { font-size: 0.95em; }
.file-meta, .file-desc { color: var(--muted); }
details.file pre { margin: 4px 0 12px; padding: 10px 12px; font-size: 0.74rem; line-height: 1.45; tab-size: 4; overflow-x: auto; background: color-mix(in srgb, var(--fg) 4%, transparent); border: 1px solid var(--line); }
.file-list { font-family: var(--font-ui); font-size: 0.82rem; columns: 1; }
.doc .file-list li { margin-bottom: 0.15em; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --plate: #f4f5f2; } }
:root[data-theme="dark"] { --plate: #f4f5f2; }
"""
THEME_JS = r"""
(function () {
const root = document.documentElement, KEY = "samon-theme";
const btns = document.querySelectorAll(".theme button");
function apply(t) {
if (t === "light" || t === "dark") root.setAttribute("data-theme", t); else root.removeAttribute("data-theme");
btns.forEach(b => b.setAttribute("aria-pressed", String(b.dataset.t === (t || "auto"))));
}
let saved = "auto"; try { saved = localStorage.getItem(KEY) || "auto"; } catch (e) {}
apply(saved);
btns.forEach(b => b.addEventListener("click", () => { apply(b.dataset.t); try { localStorage.setItem(KEY, b.dataset.t); } catch (e) {} }));
})();
"""
dates = f"{meta.get('created', '')}–{meta.get('modified', '')}"
title_html = re.sub(r"_([^_]+)_", r"<em>\1</em>", html.escape(meta["title"]))
title_text = re.sub(r"_", "", meta["title"])
# Social cards (Open Graph; Twitter reads the og: tags too, plus its own card type). The thumbnail is a garden raked at par
# (proto/test/thumbnail.py -> deliver/2026-10-05-gwern-samon-thumbnail.png); crawlers need an absolute URL, so this is where
# gwern.net would host it: adjust THUMB_URL when the page is placed.
THUMB_URL = "https://gwern.net" + meta["thumbnail"] # (the document's own thumbnail field)
SOCIAL_SUMMARY = ("A Zen-garden raking puzzle: a novice monk walks into the sand and must rake his way back out to the gate in one stroke. "
"The full design, solver experiments, 148 gardens and a year of daily ones, and a playable prototype, in one page.")
SOCIAL_META = f"""<meta property="og:type" content="article">
<meta property="og:site_name" content="Gwern.net">
<meta property="og:title" content="{html.escape(title_text)}">
<meta property="og:description" content="{html.escape(SOCIAL_SUMMARY)}">
<meta property="og:image" content="{THUMB_URL}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{html.escape(meta['thumbnail-text'])}">
<meta property="og:locale" content="en_US">
<meta property="article:author" content="{html.escape(meta['author'])}">
<meta property="article:published_time" content="{meta.get('created', '')}">
<meta property="article:modified_time" content="{meta.get('modified', '')}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@gwern">
<meta name="twitter:creator" content="@gwern">
<meta name="twitter:title" content="{html.escape(title_text)}">
<meta name="twitter:description" content="{html.escape(SOCIAL_SUMMARY)}">
<meta name="twitter:image" content="{THUMB_URL}">
<meta name="twitter:image:alt" content="{html.escape(meta['thumbnail-text'])}">"""
page = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title_text)}</title>
<meta name="description" content="{html.escape(meta['description'])}">
<meta name="author" content="{html.escape(meta['author'])}">
{SOCIAL_META}
<style>
{game_css_scoped}
{PAGE_CSS}
</style>
</head>
<body>
<div class="page">
<div class="topbar">
<span class="jump"><a href="#play" title="The playable prototype, at the top of the page">Play</a><a href="#contents" title="The document’s table of contents">Contents</a><a href="#methods" title="How the gardens were made and checked: solvers, proofs of par, the grader">Methods</a><a href="#code" title="The appendix: every file behind this page, shown and downloadable">Code</a></span>
<span class="theme" role="group" aria-label="Color theme" title="The page’s colours: as the system has them, or light or dark regardless"><button type="button" data-t="auto" title="Follow the system’s light or dark setting">Auto</button><button type="button" data-t="light" title="Light, whatever the system’s setting">Light</button><button type="button" data-t="dark" title="Dark, whatever the system’s setting">Dark</button></span>
</div>
<h1 class="title">{title_html}</h1>
<p class="subtitle">{html.escape(meta['description'])}</p>
<div class="meta">{html.escape(meta['author'])} · {dates} · status: {html.escape(meta.get('status', ''))} · confidence: {html.escape(meta.get('confidence', ''))}</div>
{str(abstract)}
<div class="play-head">[The prototype: {len(gardens)} gardens, playable here]</div>
<section id="play" class="game" aria-label="Playable prototype">
{game_markup}
</section>
<div id="contents">{toc_html}</div>
<article class="doc">
{str(soup)}
{appendix_html}
</article>
<p class="colophon">[Single-file edition: the design document, the playable prototype, and all the code and data behind them in one self-contained page (figures and files embedded; no fonts, scripts, or images loaded from elsewhere). The solvers, experiments, tests and data are in the <a href="#code">appendix</a>.]</p>
</div>
<script>
{THEME_JS}
</script>
<script>
{DOWNLOAD_JS}
</script>
<script>
{game_js}
</script>
</body>
</html>
"""
open(OUT, "w").write(page)
print(f"wrote {OUT}: {len(page):,} bytes; {nwiki} Wikipedia links resolved; {nimg} figures embedded; {len(toc_items)} TOC entries")
standalone/make_test_standalone.py — Generate standalone/test_standalone.py from proto/test/run.py: the same prototype tests, run against the single-file
"""Generate standalone/test_standalone.py from proto/test/run.py: the same prototype tests, run against the single-file
edition (the game inside the document), plus checks of the document itself (no outside requests, figures, structure,
collapsed sections, contents links, theme switch)."""
import sys
MARGIN_NOTES = 70 # margin notes in samon.md: update when the document gains or loses one
src = open('/home/claude/zen/proto/test/run.py').read()
def rep(s, old, new, count=1):
n = s.count(old)
if n != count:
sys.exit(f"expected {count} of {old[:60]!r}, found {n}")
return s.replace(old, new)
s = rep(src, "URL = 'file:///home/claude/zen/proto/test/index.html'", "URL = 'file:///home/claude/zen/standalone/2026-10-03-gwern-samon.html'")
s = rep(s, "SHOT = '/home/claude/zen/proto/test/'", "SHOT = '/home/claude/zen/standalone/shots/'")
s = rep(s, "async def centers(page, cells):\n",
"async def centers(page, cells):\n await page.evaluate(\"(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); if (r.top < 0 || r.bottom > innerHeight) cv.scrollIntoView({block: 'center'}); })()\")\n")
first_goto = " await page.goto(URL); await page.wait_for_timeout(300)\n"
i = s.index(first_goto)
block = f''' outside = []
page.on('request', lambda r: outside.append(r.url) if not r.url.startswith('file://') and not r.url.startswith('data:') else None)
await page.goto(URL); await page.wait_for_timeout(500)
expect(not outside, f'no requests beyond the file itself {{outside[:3]}}')
imgs = await page.evaluate("[...document.querySelectorAll('.doc img')].map(i => i.complete && i.naturalWidth > 0)")
expect(len(imgs) == 12 and all(imgs), f'all 12 figures decode ({{sum(imgs)}}/{{len(imgs)}})')
n = await page.evaluate("[document.querySelectorAll('.doc h1, .doc h2, details summary h1, details summary h2').length, document.querySelectorAll('.margin-note').length, document.querySelectorAll('.footnote-ref').length, document.querySelectorAll('.toc a').length]")
expect(n[0] >= 20 and n[1] == {MARGIN_NOTES} and n[2] == 2 and n[3] == 23, f'document structure (headings, margin notes, footnote refs, TOC links) {{n}}')
# the appendix: every file of the code bundle is in the page's .zip, which is the one the build wrote; the code
# shown inline is byte-for-byte the files'; the download link delivers the .zip
import base64, hashlib, io, zipfile
ZP = '/home/claude/zen/standalone/2026-10-03-gwern-samon-code.zip'
BUNDLE = '/home/claude/zen/deliver/samon-code'
ap = await page.evaluate("""(() => {{ const shown = {{}};
document.querySelectorAll('details.file').forEach(d => {{ shown[d.dataset.path] = d.querySelector('pre code').textContent; }});
return [shown, document.querySelectorAll('.appendix .file-list li').length, document.getElementById('bundle-zip').textContent.trim()]; }})()""")
expect(ap[2].startswith('data:application/zip;base64,'), 'the .zip is carried as a data: URL (which a gwtar build moves into its archive)')
zb = base64.b64decode(ap[2].split(',', 1)[1]); zdisk = open(ZP, 'rb').read()
expect(zb == zdisk, f'the page carries the .zip the build wrote ({{len(zb):,}} bytes)')
zf = zipfile.ZipFile(io.BytesIO(zb))
names = [z_[len('samon-code/'):] for z_ in zf.namelist()]
on_disk = sorted(os.path.relpath(os.path.join(d_, f_), BUNDLE) for d_, _, fs_ in os.walk(BUNDLE) for f_ in fs_)
gen_ = {{'standalone/2026-10-03-gwern-samon.html', 'proto/samon.html', 'proto/samon.standalone.html'}}
expect(names == [p_ for p_ in on_disk if p_ not in gen_], f'the .zip holds every file of the bundle but the generated pages ({{len(names)}} files)')
expect(all(zf.read('samon-code/' + p_) == open(os.path.join(BUNDLE, p_), 'rb').read() for p_ in names), 'every file in the .zip is byte-for-byte the bundle\\'s')
expect(len(ap[0]) + ap[1] == len(names) and all(zf.read('samon-code/' + p_).decode() == t_ for p_, t_ in ap[0].items()),
f'the appendix shows or lists every file, and what it shows is the file exactly ({{len(ap[0])}} shown, {{ap[1]}} listed)')
expect(all(p_ in ap[0] for p_ in names if p_.endswith(('.py', '.c', '.md')) or p_ == 'proto/samon.template.html'), 'all the code is shown')
await page.click('summary:has(h1:text("Appendix: Code and Data"))')
async with page.expect_download() as dli_:
await page.click('#bundle-dl')
dl_ = await dli_.value
got_ = open(await dl_.path(), 'rb').read()
expect(dl_.suggested_filename == '2026-10-03-gwern-samon-code.zip' and hashlib.sha256(got_).hexdigest() == hashlib.sha256(zdisk).hexdigest(), f'the download link delivers the .zip ({{dl_.suggested_filename}})')
await page.evaluate("location.hash = '#file-src-zgsolve-c'"); await page.wait_for_timeout(200)
op_ = await page.evaluate("[document.getElementById('file-src-zgsolve-c').open, Math.round(document.getElementById('file-src-zgsolve-c').getBoundingClientRect().top)]")
expect(op_[0] and abs(op_[1]) < 5, f'a link to a file opens it and scrolls to it {{op_}}')
await page.evaluate("document.getElementById('file-src-zgsolve-c').open = false; history.replaceState(null, '', location.pathname)")
await page.click('summary:has(h1:text("Appendix: Code and Data"))')
await page.click('summary:has(h1:text("Methods"))')
op = await page.evaluate("document.getElementById('methods').open")
expect(op, 'Methods expands')
await page.click('summary:has(h1:text("Methods"))')
await page.click('.toc >> text=The Wide Rake')
y = await page.evaluate("document.getElementById('the-wide-rake').getBoundingClientRect().top")
expect(abs(y) < 5, f'TOC links jump to their sections ({{y:.0f}})')
await page.evaluate("window.scrollTo(0, 0)")
await page.click('.theme >> text=Dark')
th = await page.evaluate("[document.documentElement.getAttribute('data-theme'), getComputedStyle(document.body).backgroundColor]")
expect(th[0] == 'dark' and th[1] == 'rgb(22, 22, 22)', f'theme switch to dark {{th}}')
await page.click('.theme >> text=Auto')
await page.evaluate("document.getElementById('garden').scrollIntoView({{block: 'center'}})")
'''
s = s[:i] + block + s[i + len(first_goto):]
s = rep(s, " await page.click('text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view\n",
" await page.click('#play nav >> text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view\n"
" await page.evaluate(\"document.getElementById('garden').scrollIntoView({block: 'center'})\")\n")
s = rep(s, " await page.evaluate(\"window.__samon.enterPractice()\")\n",
" await page.evaluate(\"window.__samon.enterPractice()\")\n await page.evaluate(\"document.getElementById('garden').scrollIntoView({block: 'center'})\")\n", count=2)
open('/home/claude/zen/standalone/test_standalone.py', 'w').write(s)
print('wrote standalone/test_standalone.py')
standalone/shots.py
import asyncio
from playwright.async_api import async_playwright
URL = 'file:///home/claude/zen/standalone/2026-10-03-gwern-samon.html'
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
# phone, light: top of page, then the game
page = await (await b.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=2)).new_page()
await page.goto(URL); await page.wait_for_timeout(400)
await page.screenshot(path='shots/phone-top.png')
await page.evaluate("document.getElementById('play').scrollIntoView()"); await page.wait_for_timeout(200)
await page.screenshot(path='shots/phone-game.png')
await page.evaluate("document.getElementById('the-wide-rake').scrollIntoView()"); await page.wait_for_timeout(200)
await page.screenshot(path='shots/phone-doc.png')
# desktop, light: margin notes in the margin, a table
page = await (await b.new_context(viewport={'width': 1400, 'height': 900})).new_page()
await page.goto(URL); await page.wait_for_timeout(400)
await page.evaluate("document.getElementById('the-bare-rule-is-loose').scrollIntoView()"); await page.wait_for_timeout(200)
await page.screenshot(path='shots/desktop-doc.png')
# desktop, dark: calm section with figure
ctx = await b.new_context(viewport={'width': 1400, 'height': 900}, color_scheme='dark')
page = await ctx.new_page(); await page.goto(URL); await page.wait_for_timeout(400)
await page.evaluate("document.getElementById('calm-turns-as-score').scrollIntoView()"); await page.wait_for_timeout(200)
await page.screenshot(path='shots/desktop-dark.png')
await page.evaluate("window.scrollTo(0,0)"); await page.wait_for_timeout(200)
await page.screenshot(path='shots/desktop-dark-top.png')
await b.close()
asyncio.run(main())
standalone/test_standalone.py — Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals;
"""Browser test of the prototype: every garden raked at par with real mouse drags (raked on letting go); refusals;
Restore after a rewind and after Start over; the abbot's hints (first hint, sketched groove, 'no longer at par' with its
rewind, 'stuck' with its rewind); the grey seal; unlocking; assists; the poems and the scroll of poems earned; the daily
garden; drag intent (drift, the grip beyond the walls, arcs, reversals, wobble); sound; sharing (a raking as a code in the URL's hash,
progress carried between devices, the share text); the practice garden; screenshots in light and dark."""
import asyncio, json, math, os, sys
from playwright.async_api import async_playwright
sys.path.insert(0, '/home/claude/zen/src')
from zg import Garden, check_solution, turns_of
G = json.load(open('/home/claude/zen/out/proto_gardens.json'))
URL = 'file:///home/claude/zen/standalone/2026-10-03-gwern-samon.html'
SHOT = '/home/claude/zen/standalone/shots/'
fails = []
def garden_of(p, braced=True):
marks = {tuple(map(int, k.split(','))): v for k, v in (p.get('marks') or {}).items()}
clues = {tuple(map(int, k.split(','))): v for k, v in (p['clues'] or {}).items()}
return Garden(p['rows'], tuple(p['gate']), clues=clues, marks=marks, end=tuple(p['end']) if p['end'] else None,
nohairpin=2 if p['wide'] else 0, braced_start=p['wide'] and braced, end_side=p['end_side'])
def expect(cond, what):
print(('ok ' if cond else 'FAIL ') + what)
if not cond: fails.append(what)
async def centers(page, cells):
await page.evaluate("(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); if (r.top < 0 || r.bottom > innerHeight) cv.scrollIntoView({block: 'center'}); })()")
return await page.evaluate("""(path) => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect();
const s = r.width / cv.width; const g = window.__samon.geom();
return path.map(([rr, cc]) => [r.left + (g.ox + cc * g.cs + g.cs / 2) * s, r.top + (g.oy + rr * g.cs + g.cs / 2) * s]); }""", cells)
async def drag(page, cells):
pts = await centers(page, cells)
await page.mouse.move(*pts[0]); await page.mouse.down()
for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3)
await page.mouse.up()
async def quiet(page, **t): # poems off (they would carry the page on to the next garden), unless a test wants them
await page.evaluate("(t) => { window.__samon.poemTiming = t; }", t or {'delay': 3600000})
async def state(page):
return await page.evaluate("() => ({mode: window.__samon.mode, len: window.__samon.path.length, turns: window.__samon.turnsOf(), "
"status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''), "
"restore: !document.getElementById('restore').hidden, seal: getComputedStyle(document.getElementById('seal')).display, "
"ghosts: window.__samon.ghosts.size, actions: [...document.querySelectorAll('#actions button')].map(b => b.textContent)})")
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
errors = []
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page()
page.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
page.on('pageerror', lambda e: errors.append(str(e)))
outside = []
page.on('request', lambda r: outside.append(r.url) if not r.url.startswith('file://') and not r.url.startswith('data:') else None)
await page.goto(URL); await page.wait_for_timeout(500)
expect(not outside, f'no requests beyond the file itself {outside[:3]}')
imgs = await page.evaluate("[...document.querySelectorAll('.doc img')].map(i => i.complete && i.naturalWidth > 0)")
expect(len(imgs) == 12 and all(imgs), f'all 12 figures decode ({sum(imgs)}/{len(imgs)})')
n = await page.evaluate("[document.querySelectorAll('.doc h1, .doc h2, details summary h1, details summary h2').length, document.querySelectorAll('.margin-note').length, document.querySelectorAll('.footnote-ref').length, document.querySelectorAll('.toc a').length]")
expect(n[0] >= 20 and n[1] == 69 and n[2] == 2 and n[3] == 23, f'document structure (headings, margin notes, footnote refs, TOC links) {n}')
# the appendix: every file of the code bundle is in the page's .zip, which is the one the build wrote; the code
# shown inline is byte-for-byte the files'; the download link delivers the .zip
import base64, hashlib, io, zipfile
ZP = '/home/claude/zen/standalone/2026-10-03-gwern-samon-code.zip'
BUNDLE = '/home/claude/zen/deliver/samon-code'
ap = await page.evaluate("""(() => { const shown = {};
document.querySelectorAll('details.file').forEach(d => { shown[d.dataset.path] = d.querySelector('pre code').textContent; });
return [shown, document.querySelectorAll('.appendix .file-list li').length, document.getElementById('bundle-zip').textContent.trim()]; })()""")
expect(ap[2].startswith('data:application/zip;base64,'), 'the .zip is carried as a data: URL (which a gwtar build moves into its archive)')
zb = base64.b64decode(ap[2].split(',', 1)[1]); zdisk = open(ZP, 'rb').read()
expect(zb == zdisk, f'the page carries the .zip the build wrote ({len(zb):,} bytes)')
zf = zipfile.ZipFile(io.BytesIO(zb))
names = [z_[len('samon-code/'):] for z_ in zf.namelist()]
on_disk = sorted(os.path.relpath(os.path.join(d_, f_), BUNDLE) for d_, _, fs_ in os.walk(BUNDLE) for f_ in fs_)
gen_ = {'standalone/2026-10-03-gwern-samon.html', 'proto/samon.html', 'proto/samon.standalone.html'}
expect(names == [p_ for p_ in on_disk if p_ not in gen_], f'the .zip holds every file of the bundle but the generated pages ({len(names)} files)')
expect(all(zf.read('samon-code/' + p_) == open(os.path.join(BUNDLE, p_), 'rb').read() for p_ in names), 'every file in the .zip is byte-for-byte the bundle\'s')
expect(len(ap[0]) + ap[1] == len(names) and all(zf.read('samon-code/' + p_).decode() == t_ for p_, t_ in ap[0].items()),
f'the appendix shows or lists every file, and what it shows is the file exactly ({len(ap[0])} shown, {ap[1]} listed)')
expect(all(p_ in ap[0] for p_ in names if p_.endswith(('.py', '.c', '.md')) or p_ == 'proto/samon.template.html'), 'all the code is shown')
await page.click('summary:has(h1:text("Appendix: Code and Data"))')
async with page.expect_download() as dli_:
await page.click('#bundle-dl')
dl_ = await dli_.value
got_ = open(await dl_.path(), 'rb').read()
expect(dl_.suggested_filename == '2026-10-03-gwern-samon-code.zip' and hashlib.sha256(got_).hexdigest() == hashlib.sha256(zdisk).hexdigest(), f'the download link delivers the .zip ({dl_.suggested_filename})')
await page.evaluate("location.hash = '#file-src-zgsolve-c'"); await page.wait_for_timeout(200)
op_ = await page.evaluate("[document.getElementById('file-src-zgsolve-c').open, Math.round(document.getElementById('file-src-zgsolve-c').getBoundingClientRect().top)]")
expect(op_[0] and abs(op_[1]) < 5, f'a link to a file opens it and scrolls to it {op_}')
await page.evaluate("document.getElementById('file-src-zgsolve-c').open = false; history.replaceState(null, '', location.pathname)")
await page.click('summary:has(h1:text("Appendix: Code and Data"))')
await page.click('summary:has(h1:text("Methods"))')
op = await page.evaluate("document.getElementById('methods').open")
expect(op, 'Methods expands')
await page.click('summary:has(h1:text("Methods"))')
await page.click('.toc >> text=The Wide Rake')
y = await page.evaluate("document.getElementById('the-wide-rake').getBoundingClientRect().top")
expect(abs(y) < 5, f'TOC links jump to their sections ({y:.0f})')
await page.evaluate("window.scrollTo(0, 0)")
await page.click('.theme >> text=Dark')
th = await page.evaluate("[document.documentElement.getAttribute('data-theme'), getComputedStyle(document.body).backgroundColor]")
expect(th[0] == 'dark' and th[1] == 'rgb(22, 22, 22)', f'theme switch to dark {th}')
await page.click('.theme >> text=Auto')
await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})")
await quiet(page)
st = await state(page)
expect(st['seal'] == 'none' and not st['restore'] and not await page.query_selector('#rake'), 'seal and Restore hidden at load; no Rake button')
# 0. a first visit: the first garden is raked once by the monk (walking in, then raking out) and reset for the player;
# then the monk waits in the gateway, a ring beckons, and a ghostly finger shows the drag
onboard = """() => ({beckon: !document.getElementById('beckon').hidden, finger: !document.getElementById('finger').hidden,
anim: document.getElementById('finger').getAnimations().length, status: document.getElementById('status').textContent.replace(/^\\[|\\]$/g, ''),
started: localStorage.getItem('samon-started')})"""
g0 = G[0]
expect(g0['demo'] and G[1]['demo'] and not G[2]['demo'], 'the first two gardens have demonstrations, the first real garden (third) none')
await page.wait_for_function("window.__samon.mode === 'demo'", timeout=4000)
st = await state(page); ob = await page.evaluate(onboard)
expect(st['status'].startswith('Watch') and not ob['beckon'], f"the first garden demonstrates itself, and nothing beckons meanwhile ({st['status']!r})")
await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=8000)
walked = await page.evaluate("window.__samon.path.slice()")
want = await page.evaluate("(cells) => cells.map(([r, c]) => window.__samon.key(r, c))", g0['intended'])
expect(walked == want, 'the demonstration walks the intended raking, then rakes it')
await page.wait_for_function("!window.__samon.demoRunning", timeout=20000)
st = await state(page); ob = await page.evaluate(onboard)
pill0 = await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(g0['name'])}).className")
expect(st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'] and st['seal'] == 'none' and 'done' not in pill0 and 'sealed' not in pill0,
f"then the garden is reset for the player, and the demonstration counts for nothing ({st['status']!r}, {pill0!r})")
expect(ob['beckon'] and ob['finger'] and ob['anim'] == 1, f'then the waiting monk beckons, and a finger shows the drag {ob}')
br = await page.evaluate("[document.getElementById('status').textContent, document.querySelector('.help.puzzle-only').textContent.trim(), document.querySelector('#nav .lock').textContent, document.getElementById('rule').firstChild.textContent, document.getElementById('rule').querySelector('.facts').textContent, [...document.querySelectorAll('#actions button, .veranda button')].map(b => b.textContent)]")
expect(all(t.startswith('[') and t.endswith(']') for t in br[:3]) and br[3].startswith('[') and not br[4].startswith('[') and not any(t.startswith('[') for t in br[5]),
f"what the game says to the player is set in editorial brackets (status, help, lock notes, a garden's instructions), and rules, counts and controls are not ({br[0][:40]!r}, {br[3][:30]!r}, {br[4][:20]!r})")
await page.reload(); await page.wait_for_timeout(1500)
await quiet(page)
st = await state(page); ob = await page.evaluate(onboard)
expect(not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and 'Drag the monk in' in st['status'] and ob['beckon'] and ob['finger'],
f"a demonstration seen once does not play again by itself ({st['status']!r})")
wv = await page.evaluate("!document.getElementById('watch').hidden")
await page.click('#watch'); await page.wait_for_timeout(700)
running = await page.evaluate("window.__samon.demoRunning")
mid = await centers(page, [g0['intended'][1]]); await page.mouse.click(*mid[0]); await page.wait_for_timeout(100)
st = await state(page)
expect(wv and running and not await page.evaluate("window.__samon.demoRunning") and st['len'] == 0 and st['mode'] == 'draw' and 'Your turn' in st['status'],
'Watch replays the demonstration, and a tap skips it')
await page.evaluate("window.__samon.load(2)")
expect(await page.evaluate("document.getElementById('watch').hidden"), 'a garden without a demonstration has no Watch button')
await page.evaluate("window.__samon.load(0)")
far = await centers(page, [[0, len(g0['rows'][0]) - 1]])
await page.mouse.click(*far[0]); st = await state(page)
expect('Start at the gate' in st['status'] and st['len'] == 0, 'a press away from the gate says where to start')
gp = await page.evaluate("(() => { const cv = document.getElementById('garden'); const r = cv.getBoundingClientRect(); const s = r.width / cv.width; const [x, y] = window.__samon.gate(); return [r.left + x * s, r.top + y * s]; })()")
pts = await centers(page, g0['intended'][:3])
await page.mouse.move(*gp); await page.mouse.down()
for (x, y) in pts: await page.mouse.move(x, y, steps=3)
await page.mouse.up(); st = await state(page); ob = await page.evaluate(onboard)
expect(st['len'] == 3 and not ob['beckon'] and not ob['finger'] and ob['started'] == '1', f'dragging the monk in from the gateway starts the walk, and the beckoning stops {ob}')
await page.screenshot(path=SHOT + 'shot-phone-light-start.png', full_page=False)
await page.evaluate("window.__samon.load(2)"); ob = await page.evaluate(onboard)
expect(ob['beckon'] and not ob['finger'], f'after the first walk, fresh gardens still beckon, without the finger {ob}')
# 1. every garden at par, by mouse: letting go of a complete walk rakes it
for i, g in enumerate(G if not os.environ.get('SAMON_QUICK') else G[:3]):
await page.evaluate(f"window.__samon.load({i})")
await drag(page, g['intended'])
await page.wait_for_timeout(350)
st = await state(page)
ok = st['len'] == len(g['intended']) and st['turns'] == g['par'] and st['mode'] == 'reveal'
pts = await centers(page, [g['intended'][0]]); await page.mouse.click(*pts[0]) # skip the reveal
await page.wait_for_timeout(60)
st2 = await state(page)
expect(ok and st2['mode'] == 'done' and st2['seal'] == 'grid' and 'par' in st2['status'], f"{g['name']}: drawn, raked on letting go, at par, sealed")
# 2. refusals: wide rake U-turn; pebbles
wi = next(i for i, g in enumerate(G) if g['name'] == 'Wide rake')
await page.evaluate(f"window.__samon.load({wi})")
await drag(page, [[6, 5], [5, 5], [4, 5], [4, 6], [5, 6]])
st = await state(page)
expect('wide rake' in st['status'] and st['len'] == 4, 'wide rake refuses an open-sand U-turn')
pi = next(i for i, g in enumerate(G) if g['name'] == "The abbot's pebbles")
pg = G[pi]; marks = {tuple(map(int, k.split(','))): v for k, v in pg['marks'].items()}
rows = pg['rows']; seq = [tuple(c) for c in pg['intended']]
j = next(k for k in range(1, len(seq) - 1) if marks.get(seq[k]) == 'S'
and any(0 <= seq[k][0] + a < len(rows) and 0 <= seq[k][1] + bb < len(rows[0]) and rows[seq[k][0] + a][seq[k][1] + bb] == '.'
and (seq[k][0] + a, seq[k][1] + bb) not in seq[:k + 1]
and (a, bb) != (seq[k][0] - seq[k - 1][0], seq[k][1] - seq[k - 1][1])
and (a, bb) != (seq[k - 1][0] - seq[k][0], seq[k - 1][1] - seq[k][1]) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1))))
v = seq[j]; din = (v[0] - seq[j - 1][0], v[1] - seq[j - 1][1])
turn = next((v[0] + a, v[1] + bb) for a, bb in ((-1, 0), (1, 0), (0, -1), (0, 1))
if (a, bb) != din and (a, bb) != (-din[0], -din[1]) and 0 <= v[0] + a < len(rows) and 0 <= v[1] + bb < len(rows[0])
and rows[v[0] + a][v[1] + bb] == '.' and (v[0] + a, v[1] + bb) not in seq[:j + 1])
await page.evaluate(f"window.__samon.load({pi})")
await drag(page, [list(c) for c in seq[:j + 1]] + [list(turn)])
st = await state(page)
expect('pale pebble' in st['status'] and st['len'] == j + 1, 'a pale pebble refuses a turn')
# 3. Restore after a tap-rewind and after Start over
ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm')
await page.evaluate(f"window.__samon.load({ci})")
await page.click('#restart')
await drag(page, G[ci]['intended'][:12])
pts = await centers(page, [G[ci]['intended'][3]]); await page.mouse.click(*pts[0])
st = await state(page)
expect(st['len'] == 4 and st['restore'], 'tapping a footprint rewinds to it, and offers Restore')
await page.click('#restore'); st = await state(page)
expect(st['len'] == 12 and not st['restore'], 'Restore brings the rewound route back')
await page.click('#restart'); st = await state(page)
expect(st['len'] == 0 and st['restore'], 'Start over clears the route, and offers Restore')
await page.click('#restore'); st = await state(page)
expect(st['len'] == 12, 'Restore after Start over')
# 3b. after raking: a tap on a cell walks back to it (the sand is smoothed back to the footprints), Restore rakes it again;
# holding at the last cell does not rake until the mouse lets go; the keyboard rakes after a beat
await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart')
full = G[ci]['intended']
pts = await centers(page, full)
await page.mouse.move(*pts[0]); await page.mouse.down()
for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=3)
await page.wait_for_timeout(700); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == len(full), 'a complete walk is not raked while the mouse is still down')
await page.mouse.up(); await page.wait_for_timeout(350); st = await state(page)
expect(st['mode'] == 'reveal', 'letting go starts the raking')
await page.mouse.click(*pts[0]); await page.wait_for_timeout(700)
k = len(full) - 5
await page.mouse.click(*pts[k - 1]); await page.wait_for_timeout(100); st = await state(page)
ref = await page.evaluate("window.__samon.sand()")
expect(st['mode'] == 'draw' and st['len'] == k and st['seal'] == 'none' and st['restore'], 'after raking, tapping a cell walks back to it, and offers Restore')
await page.evaluate("(w) => window.__samon.setPath(w)", full[:k]); fresh = await page.evaluate("window.__samon.sand()")
expect(abs(ref - fresh) < 1e-6, 'walking back smooths the raked sand back to the footprints')
await page.evaluate("(w) => window.__samon.setPath(w)", full)
await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'a walk completed any other way is raked after a beat')
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(60); st = await state(page)
expect(st['mode'] == 'done', 'Enter skips the raking')
await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == len(full) - 1, 'Backspace after raking steps back one footprint')
await page.click('#restore'); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] in ('reveal', 'done') and st['len'] == len(full), 'Restore brings the whole walk back, and it is raked again')
await page.keyboard.press('Enter'); await page.wait_for_timeout(60)
# the keyboard: the last arrow rakes after a beat, in which Backspace can still take it back
ti = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares')
await page.evaluate(f"window.__samon.load({ti})"); await page.focus('#garden')
keys = ('ArrowUp', 'ArrowUp', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowUp') # in at the gate, then a serpentine
for kk in keys: await page.keyboard.press(kk)
await page.keyboard.press('Backspace'); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == 8, 'Backspace in the beat takes the last step back, and nothing is raked')
await page.keyboard.press(keys[-1]); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] in ('reveal', 'done') and st['len'] == 9, 'from the keyboard, a complete walk is raked after a beat')
await page.keyboard.press('Enter'); await page.wait_for_timeout(60); await page.keyboard.press('Enter'); await page.wait_for_timeout(800)
nm = await page.evaluate("window.__samon.G.name")
expect(nm == G[ti + 1]['name'], 'Enter after raking goes on to the next garden')
# a walk that covers every cell but may not end where it does is not raked
wi2, bad = None, None
for j, gg in enumerate(G):
if not gg['wide'] or gg['end']: continue
gx = garden_of(gg, braced=False)
for s_ in gx.solve(mode=0, cap=400, stream=True)['sols']:
if not check_solution(garden_of(gg), s_['draw']): wi2, bad = j, [list(c) for c in s_['draw']]; break
if bad: break
await page.evaluate(f"window.__samon.load({wi2})"); await drag(page, bad); await page.wait_for_timeout(600); st = await state(page)
expect(st['mode'] == 'draw' and st['len'] == len(bad) and 'wide rake down' in st['status'], f"a complete walk ending in open sand under the wide rake is not raked ({G[wi2]['name']})")
# 4. the abbot's hints
await page.evaluate(f"window.__samon.load({ci})")
await page.click('#restart'); await page.click('#hint'); st = await state(page)
expect(G[ci]['hint'] in st['status'], 'first hint is the authored observation')
await page.click('#hint'); st = await state(page)
expect('at par' in st['status'] and st['ghosts'] == 1, 'second hint: route can reach par, one groove sketched')
found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30);
const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", found)
await page.click('#hint'); st = await state(page)
expect('no longer at par' in st['status'] and any('for par' in a for a in st['actions']), "'no longer at par' with a rewind offer")
await page.click('#actions button >> nth=0'); st = await state(page)
a = await page.evaluate("window.__samon.analyze(window.__samon.path).state")
expect(a == 'par' and st['restore'], 'the par rewind lands on a route that can reach par, and is restorable')
fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent.replace(/^\\[|\\]$/g, '')]")
n0 = await page.evaluate("window.__samon.path.length"); await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowRight'); n1 = await page.evaluate("window.__samon.path.length")
expect(fk[0] == 'garden' and fk[1].startswith('Stepped back') and 'par is still possible' in fk[1] and 'no longer at par' not in fk[1] and n1 > n0,
f"after the abbot's rewind the garden has the keyboard again, and the old message has given way to what now holds ({fk[1][:70]!r})")
found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 8 + s % 30);
const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'stuck' && a.back < w.length - 1 && a.why.cells.length) return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", found)
await page.click('#hint'); st = await state(page)
expect('to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), "'stuck': reason, marked cells, rewind offer, action first")
await page.screenshot(path=SHOT + 'shot-phone-light-stuck.png', full_page=True)
await page.click('#actions button >> nth=0')
a = await page.evaluate("window.__samon.analyze(window.__samon.path).state")
fk = await page.evaluate("[document.activeElement && document.activeElement.id, document.getElementById('status').textContent]")
expect(a in ('par', 'finish') and fk[0] == 'garden' and 'can be finished from here' in fk[1] and 'marked footprint' not in fk[1], f"the rewind lands on a route that can be finished, says so, and keeps the keyboard ({fk[1][:60]!r})")
# 5. assists
await page.evaluate(f"window.__samon.load({ci})"); await page.click('#restart')
await drag(page, G[ci]['intended'][:20] if False else [[6, 3], [5, 3], [5, 2], [5, 1], [5, 0], [4, 0], [3, 0], [3, 1], [4, 1], [4, 2], [4, 3], [4, 4], [3, 4], [3, 5]])
for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid)
pressed = await page.evaluate("['aPreview','aChecker','aCut'].map(id => document.getElementById(id).getAttribute('aria-pressed'))")
expect(pressed == ['true'] * 3, 'assist toggles switch on')
await page.screenshot(path=SHOT + 'shot-phone-light-assists.png', full_page=True)
for bid in ('#aPreview', '#aChecker', '#aCut'): await page.click(bid)
# 6. practice garden
await page.click('#play nav >> text=Practice garden'); await page.wait_for_timeout(900) # the page scrolls the garden into view
await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})")
s0 = await page.evaluate("window.__samon.practice()")
box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()")
L, T, W, H = box
import math
for k, (cx, cy, rad) in enumerate([(0.27, 0.40, 0.18), (0.72, 0.65, 0.14)]):
pts = [(L + W * (cx + rad * math.cos(t / 30 * 2 * math.pi) * 0.75), T + H * (cy + rad * math.sin(t / 30 * 2 * math.pi))) for t in range(0, 34)]
await page.mouse.move(*pts[0]); await page.mouse.down()
for (x, y) in pts[1:]: await page.mouse.move(x, y, steps=4)
await page.mouse.up()
for yy in (0.1, 0.16, 0.86, 0.92):
await page.mouse.move(L + W * 0.05, T + H * yy); await page.mouse.down()
await page.mouse.move(L + W * 0.95, T + H * yy, steps=40); await page.mouse.up()
s1 = await page.evaluate("window.__samon.practice()")
expect(abs(s1['sum'] - s0['sum']) > 50, 'raking changes the practice sand')
await page.screenshot(path=SHOT + 'shot-phone-light-practice.png', full_page=True)
await page.click('#toolStone'); await page.mouse.click(L + W * 0.5, T + H * 0.2)
s2 = await page.evaluate("window.__samon.practice()")
expect(s2['stones'] == s0['stones'] + 1, 'Stones tool places a stone')
await page.mouse.click(L + W * 0.5, T + H * 0.2)
s3 = await page.evaluate("window.__samon.practice()")
expect(s3['stones'] == s0['stones'], 'tapping a stone lifts it')
await page.click('#smooth'); s4 = await page.evaluate("window.__samon.practice()")
expect(abs(s4['sum'] - s0['sum']) < 1e-6, 'Smooth restores the unraked sand')
await page.click('#nav [aria-label="Calm"]'); st = await state(page)
expect(await page.evaluate("window.__samon.view") == 'puzzle', 'back from the practice garden to a garden')
await ctx.close()
# 7. dark mode: a finished garden, and the practice garden
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='dark')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page, delay=100, hold=3600000) # the screenshot shows the raked garden with its poem
si = next(i for i, g in enumerate(G) if g['name'] == 'Spiral')
await page.evaluate(f"window.__samon.load({si})"); await drag(page, G[si]['intended'])
await page.wait_for_timeout(7200)
await page.screenshot(path=SHOT + 'shot-phone-dark-done.png', full_page=True)
await page.evaluate("window.__samon.enterPractice()")
await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})")
box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left, r.top, r.width, r.height]; })()")
L, T, W, H = box
for yy in (0.25, 0.42, 0.59, 0.76):
await page.mouse.move(L + W * 0.06, T + H * yy); await page.mouse.down()
await page.mouse.move(L + W * 0.94, T + H * (yy + 0.04), steps=40); await page.mouse.up()
await page.screenshot(path=SHOT + 'shot-phone-dark-practice.png', full_page=True)
await ctx.close()
# 7b. the grey seal: help beyond the abbot's first observation marks the raking (fresh profile)
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
ci = next(i for i, g in enumerate(G) if g['name'] == 'Calm')
ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares')
async def rake_intended(i):
await drag(page, G[i]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60)
return await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]")
async def pill_class(name):
return await page.evaluate(f"[...document.querySelectorAll('#nav .pip')].find(b => b.getAttribute('aria-label') === {json.dumps(name)}).className")
await page.evaluate(f"window.__samon.load({ni})")
await page.click('#hint') # the authored observation only: free
r1 = await rake_intended(ni)
expect(r1[0] == 'grid' and not r1[1] and 'grey' not in r1[2], 'the first observation alone leaves the seal vermilion')
await page.evaluate(f"window.__samon.load({ci})")
await page.click('#hint'); await page.click('#hint') # observation, then a verdict: help
st = await state(page)
expect('grey seal' in st['status'], 'the first real help says the seal will be grey')
await page.click('#restart') # starting over does not undo help
r2 = await rake_intended(ci)
expect(r2[0] == 'grid' and r2[1] and 'grey' in r2[2], 'a helped raking at par earns the grey seal, even after Start over')
expect('grey' in await pill_class('Calm'), 'the garden list shows a grey seal')
await page.click('#restart')
r3 = await rake_intended(ci)
expect(r3[0] == 'grid' and not r3[1] and 'grey' not in r3[2], 'raking it again unaided earns the vermilion seal')
expect('sealed' in await pill_class('Calm'), 'the garden list now shows the vermilion seal')
sand0 = await page.evaluate("window.__samon.sand()")
await page.click('#restart')
sand1 = await page.evaluate("window.__samon.sand()")
expect(abs(sand0 - sand1) > 1, 'Start over smooths the raked sand back to unraked')
# help travels with the route it helped: stepping back into a helped raking and restoring it re-rakes it grey
ti = next(i for i, g in enumerate(G) if g['name'] == 'The pocket')
await page.evaluate(f"window.__samon.load({ti})")
await page.click('#hint'); await page.click('#hint')
r4 = await rake_intended(ti)
await page.focus('#garden'); await page.keyboard.press('Backspace'); await page.wait_for_timeout(60)
h = await page.evaluate("window.__samon.helped")
async def rerake(): # Restore rakes again after a beat; Enter (on the garden) skips it
await page.click('#restore')
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
if await page.evaluate("window.__samon.mode") == 'reveal':
await page.focus('#garden'); await page.keyboard.press('Enter')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=3000)
await rerake()
r5 = await page.evaluate("[getComputedStyle(document.getElementById('seal')).display, document.getElementById('seal').classList.contains('grey'), document.getElementById('status').textContent]")
expect(r4[1] and h and r5[0] == 'grid' and r5[1] and 'grey' in r5[2], f'Step back, then Restore, re-rakes a helped raking with the grey seal {r5}')
tp = await centers(page, [G[ti]['intended'][2]]); await page.wait_for_timeout(700); await page.mouse.click(*tp[0])
await page.wait_for_function("window.__samon.mode === 'draw'", timeout=3000)
await rerake()
r6 = await page.evaluate("document.getElementById('seal').classList.contains('grey')")
expect(r6, 'so does tapping back into it, then Restore')
# an answer the abbot can't give in time costs nothing
await page.evaluate(f"window.__samon.load({ci})")
found = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 10 + s % 30);
const a = window.__samon.analyzeFor({ci}, w); if (a.state === 'finish') return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", found)
h0 = await page.evaluate("window.__samon.helped")
await page.evaluate("window.__samon.budget = 1"); await page.click('#hint')
st = await state(page); h1 = await page.evaluate("window.__samon.helped")
await page.evaluate("window.__samon.budget = 250000")
expect(not h0 and "can't be sure" in st['status'] and 'costs you nothing' in st['status'] and not h1, f"an inconclusive answer costs nothing ({st['status']!r})")
# a walk that has stopped: why is free, where to step back to is help
dead = await page.evaluate(f"""(() => {{ for (let s = 1; s < 4000; s++) {{ const w = window.__samon.randomWalk({ci}, s, 999);
if (w.length < window.__samon.G.nSand && w.length > 3) return w; }} return null; }})()""")
await page.evaluate("(w) => window.__samon.setPath(w)", dead); await page.click('#hint')
st = await state(page); h2 = await page.evaluate("window.__samon.helped")
expect('Saying why a walk has stopped is free' in st['status'] and not h2 and st['actions'] == ['Where can I step back to?'],
f"a stopped walk: the abbot says why for free ({st['status']!r}, {st['actions']})")
await page.click('#actions button >> nth=0')
st = await state(page); h3 = await page.evaluate("window.__samon.helped")
expect(h3 and 'to the marked footprint' in st['status'] and any(a.startswith('Step back') for a in st['actions']), 'asking where to step back to is help')
await ctx.close()
# 7c. worlds open as the world before them is raked (two-thirds of it, at any turn count); the prototype can open all
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
w1 = [i for i, g in enumerate(G) if g['world'] == 1]
w1m = [i for i in w1 if not G[i]['extra']]; need = -(-2 * len(w1m) // 3)
nx = await page.evaluate("[document.querySelectorAll('#nav .pip.extra').length, document.querySelectorAll('#nav .more').length]")
expect(nx[0] == sum(g['extra'] for g in G) and nx[1] == len({g['world'] for g in G if g['extra']}),
f"the extras are listed after each world's main path, marked as more ({nx})")
locked = await page.evaluate("[...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length")
note = await page.evaluate("document.querySelector('#nav .lock')?.textContent || ''")
expect(locked == len(G) - len(w1) and f'{need} gardens of Dawn' in note, f'at first only Dawn is open ({locked} locked; {note!r})')
await page.click('#openAll')
locked2 = await page.evaluate("[...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length")
await page.click('#openAll')
locked3 = await page.evaluate("[...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length")
expect(locked2 == 0 and locked3 == locked, 'the prototype switch opens every world, and locks them again')
for k, i in enumerate(w1m[:need]):
await page.evaluate(f"window.__samon.load({i})"); await page.click('#restart')
await drag(page, G[i]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[i]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(60)
if k == need - 2:
still = await page.evaluate("window.__samon.worldOpen(2)")
st = await state(page)
open2 = await page.evaluate("[window.__samon.worldOpen(2), window.__samon.worldOpen(3), [...document.querySelectorAll('#nav .pip')].filter(b => b.disabled).length]")
expect(not still and open2[0] and not open2[1] and 'Morning is open' in st['status'] and open2[2] == locked - len([g for g in G if g['world'] == 2]),
f'raking {need} of the {len(w1m)} Dawn gardens on the main path opens Morning, and says so {open2}')
# Next follows the main path: from the last Dawn garden on it, past the extras, to the first of Morning; that
# garden introduces a rule, and its demonstration plays by itself
last1 = w1m[-1]
await page.evaluate(f"window.__samon.load({last1})"); await page.click('#restart')
await drag(page, G[last1]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[last1]['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100)
await page.click('#next'); await page.wait_for_timeout(800)
nm = await page.evaluate("window.__samon.G.name")
first2 = next(g for g in G if g['world'] == 2 and not g['extra'])
expect(nm == first2['name'], f'Next from the end of the main path skips the extras ({nm})')
if not first2['demo']:
i2 = G.index(first2)
await drag(page, first2['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [first2['intended'][0]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(100)
await page.click('#next'); await page.wait_for_timeout(800)
nm2 = await page.evaluate("window.__samon.G.name")
dg = next(g for g in G if g['name'] == nm2)
await page.wait_for_timeout(700)
expect(dg['demo'] and await page.evaluate("window.__samon.demoRunning"), f'the garden that brings in the ripple stone demonstrates itself ({nm2})')
await page.mouse.click(*(await centers(page, [dg['intended'][0]]))[0]); await page.wait_for_timeout(100)
# the abbot's observation points at its cells, and they stay marked while the walk goes on
pk = next(i for i, g in enumerate(G) if g['name'] == 'The pocket')
await page.evaluate(f"window.__samon.load({pk})"); await page.click('#restart'); await page.click('#hint')
st = await state(page); ob = await page.evaluate("window.__samon.obs.length")
await drag(page, G[pk]['intended'][:3]); ob2 = await page.evaluate("window.__samon.obs.length")
expect('He points' in st['status'] and ob == 1 and ob2 == 1, 'the observation points at its cell, which stays marked while drawing')
await ctx.close()
# 8. desktop: the pebbles garden mid-route
ctx = await b.new_context(viewport={'width': 1200, 'height': 1000}, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
await page.evaluate(f"window.__samon.load({pi})"); await drag(page, G[pi]['intended'][:15])
await page.screenshot(path=SHOT + 'shot-desktop-light.png', full_page=True)
await ctx.close()
# 9. properties, over every garden
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page)
bad_bends = []
for i, g in enumerate(G):
await page.evaluate(f"window.__samon.load({i})")
arcs, turns = await page.evaluate("(c) => window.__samon.strokeBends(c)", g['intended'])
if not (arcs == turns == g['par']): bad_bends.append((g['name'], arcs, turns, g['par']))
nside = sum(1 for g in G if g['end'])
expect(not bad_bends, f'every garden: the bends drawn in the raked stroke = the turns counted = par ({nside} with a side gate) {bad_bends[:5]}')
piv = await page.evaluate("""(n) => { let pairs = 0, bad = 0; for (let i = 0; i < n; i++) { window.__samon.load(i); const G = window.__samon.G;
if (!G.wide) continue;
for (const v of G.sandList) for (let d = 0; d < 4; d++) { const r = Math.floor(v / G.W) + [-1, 0, 1, 0][d], c = v % G.W + [0, 1, 0, -1][d];
if (r >= 0 && r < G.H && c >= 0 && c < G.W && G.rows[r][c] === 'R') { pairs++; if (G.piv[v * 4 + d]) bad++; } } }
return [pairs, bad]; }""", len(G))
expect(piv[0] > 0 and piv[1] == 0, f'under the wide rake, finished sand is never a pivot ({piv[0]} cell sides checked)')
wrong, seen = [], 0
quick = bool(os.environ.get('SAMON_QUICK')) # the abbot's explanations: 400 random routes per garden in full, a sample when quick
for i, g in enumerate(G):
if not (g['end'] or g['wide'] or g.get('marks')): continue
res = await page.evaluate(f"""(() => {{ const out = []; for (let s = 1; s < {30 if quick else 400}; s++) {{ const w = window.__samon.randomWalk({i}, s, 999);
const a = window.__samon.analyzeFor({i}, w); if (a.state === 'stuck' && a.why && /only one way in, so the walk would have to end there/.test(a.why.text))
out.push([a.why.text, a.why.cells[0]]); }} return out; }})()""")
W_ = len(g['rows'][0]); end = g['end'][0] * W_ + g['end'][1] if g['end'] else -1
marks = {int(k.split(',')[0]) * W_ + int(k.split(',')[1]) for k in (g.get('marks') or {})}
for text, cell in res:
seen += 1
want = 'side gate' if end >= 0 and cell != end else 'pebble' if cell in marks else 'wide rake'
if want not in text: wrong.append((g['name'], text))
expect(seen > 0 and not wrong, f'a dead-end cell is explained by the rule that actually fails ({seen} explanations checked) {wrong[:3]}')
await ctx.close()
# 10. the poem is the reward: after any raking (here off par, and with the abbot's help) a poem is laid over the
# garden, and then the next garden fades in; a tap on the poem goes on, a tap beside it stays; 'original' shows the Japanese; Enter goes on at once; a
# demonstration earns no poem; the practice garden has one
PO = json.load(open('/home/claude/zen/poems/samon-poems.json'))
ppoem = lambda name: PO['poems'][PO['gardens'][name]]
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
pc = await page.evaluate("window.__samon.poems()")
expect(pc['gardens'] == len(G) and pc['practice'], f'every garden has a poem, and so does the practice garden {pc}')
await quiet(page, delay=200, hold=1500, fade=300)
ni = next(i for i, g in enumerate(G) if g['name'] == 'Nine squares')
gx = garden_of(G[ni])
sub = next(s_['draw'] for s_ in gx.solve(mode=0, cap=100, stream=True)['sols'] if turns_of(gx, s_['draw']) > G[ni]['par'])
await page.evaluate(f"window.__samon.load({ni})")
await page.click('#hint'); await page.click('#hint')
await drag(page, [list(c) for c in sub]); await page.wait_for_timeout(350)
pts = await centers(page, [list(sub[0])]); await page.mouse.click(*pts[0])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(400)
po = await page.evaluate("window.__samon.poem")
expect(po['visible'] and po['verse'] == ppoem('Nine squares')['translation'], f"a raking off par, made with help, still earns its poem ({po['verse'][:40]!r})")
nxt = G[ni + 1]['name']
await page.wait_for_function("window.__samon.poem.leaving", timeout=4000)
await page.wait_for_function(f"window.__samon.G.name === {json.dumps(nxt)}", timeout=3000)
st = await state(page); po = await page.evaluate("window.__samon.poem")
expect(st['mode'] == 'draw' and po['state'] == 'none' and not po['visible'], f'then garden and poem fade, and the next garden fades in ({nxt})')
i2 = ni + 1
await quiet(page, delay=200, hold=1200, fade=300)
await drag(page, G[i2]['intended']); await page.wait_for_timeout(350)
pts = await centers(page, [G[i2]['intended'][0]]); await page.mouse.click(*pts[0])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=3000); await page.wait_for_timeout(300)
beside = await page.evaluate("(() => { const r = document.getElementById('poem').getBoundingClientRect(), c = document.querySelector('#poem .card').getBoundingClientRect(); return [r.left + 3, c.top > r.top + 6 ? r.top + 3 : r.top + r.height - 3]; })()")
await page.mouse.click(*beside); await page.wait_for_timeout(2000)
st = await state(page); po = await page.evaluate("window.__samon.poem"); nm = await page.evaluate("window.__samon.G.name")
expect(nm == G[i2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Staying' in st['status'] and 'Read the poem again' in st['actions'],
f"a tap beside the poem puts it away and stays with the garden ({st['status'][-60:]!r})")
await page.click('#actions button:has-text("Read the poem again")'); await page.wait_for_timeout(1800)
po = await page.evaluate("window.__samon.poem")
expect(po['visible'], 'the poem can be read again')
await page.click('#orig'); await page.wait_for_timeout(100)
po = await page.evaluate("window.__samon.poem")
expect(po['ja'] == ppoem(G[i2]['name'])['japanese'] and not po['clash'], "'original' shows the Japanese (and no line runs under the seal)")
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_timeout(800)
nm = await page.evaluate("window.__samon.G.name")
expect(nm == G[i2 + 1]['name'], 'Enter goes on to the next garden at once')
await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 1]['intended'])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(200)
card = await page.evaluate("(() => { const c = document.querySelector('#poem .card').getBoundingClientRect(); return [c.left + c.width / 2, c.top + c.height / 2]; })()")
await page.mouse.click(*card); await page.wait_for_timeout(900)
nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem")
expect(nm == G[i2 + 2]['name'] and po['state'] == 'none', f'a tap on the poem itself goes on to the next garden at once ({nm!r})')
await page.evaluate("(w) => window.__samon.setPath(w)", G[i2 + 2]['intended'])
await page.wait_for_function("window.__samon.poem.state === 'shown'", timeout=12000); await page.wait_for_timeout(700) # (a tap within 0.6 s of the raking is taken as a skip of it)
wall = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + 4, r.top + 4]; })()")
await page.mouse.click(*wall); await page.wait_for_timeout(300)
nm = await page.evaluate("window.__samon.G.name"); po = await page.evaluate("window.__samon.poem"); st = await state(page)
expect(nm == G[i2 + 2]['name'] and po['state'] == 'stay' and not po['visible'] and 'Read the poem again' in st['actions'], f'a tap on the walls around the poem puts it away and stays ({nm!r}, {po["state"]})')
fit = await page.evaluate(f"""(() => {{ const out = [], fs = [], moved = [0, 0]; for (let i = 0; i < {len(G)}; i++) for (const o of [false, true]) {{
const r = window.__samon.poemFit(i, o); fs.push(r.fs); moved[0] += r.left; moved[1] += r.under;
if (r.over || (r.clash && !r.under) || (r.under && !o)) out.push([i, o, r]); }} return [out, Math.min(...fs), moved]; }})()""")
expect(not fit[0], f'on a phone, every poem fits its garden and clears the seal (set flush left {fit[2][0]} times, over a faded seal {fit[2][1]} times, '
f'only with the original; smallest type {fit[1]:.1f}px) {fit[0][:3]}')
await page.evaluate("window.__samon.load(0)"); await page.evaluate("window.__samon.startDemo()")
await page.wait_for_function("document.getElementById('status').textContent.startsWith('[That is')", timeout=15000)
po = await page.evaluate("window.__samon.poem")
expect(po['state'] == 'none' and not po['visible'], 'a demonstration earns no poem')
await page.evaluate("window.__samon.enterPractice()")
await page.evaluate("document.getElementById('garden').scrollIntoView({block: 'center'})")
pp = await page.evaluate("document.getElementById('ppoem').textContent")
expect(PO['poems'][PO['practice']]['translation'].split('\n')[0] in pp, 'the practice garden has its poem')
await ctx.close()
# 11. the scroll of poems earned, and the daily garden (one for everyone, picked by the date; raked once, by Rake)
DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json'))
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200)
await quiet(page, delay=200, hold=3600000)
await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150)
sc0 = await page.evaluate("[document.querySelectorAll('#scroll .entry').length, getComputedStyle(document.getElementById('scroll')).display !== 'none', getComputedStyle(document.getElementById('stage')).display, window.__samon.view]")
expect(sc0 == [1, True, 'none', 'poems'], f'the scroll before any raking: only the practice poem, shown in place of the garden {sc0}')
for i in (0, 1):
await page.evaluate(f"window.__samon.load({i})")
await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150)
sc = await page.evaluate("[[...document.querySelectorAll('#scroll .entry .verse')].map(e => e.textContent), [...document.querySelectorAll('#scroll .entry .ja')].map(e => e.textContent), [...document.querySelectorAll('#scroll .left')].map(e => e.textContent)]")
expect(sc[0][:2] == [ppoem(G[0]['name'])['translation'], ppoem(G[1]['name'])['translation']] and sc[1][:2] == [ppoem(G[0]['name'])['japanese'], ppoem(G[1]['name'])['japanese']] and len(sc[0]) == 3,
f'two gardens raked: their poems, in order, with the Japanese, then the practice poem ({len(sc[0])})')
expect(sc[2][0].startswith('[2 of') and sc[2][1] == f"[{sum(1 for g in G if g['world'] == 1) - 2} more in the gardens of Dawn.]", f'the scroll counts what is earned and what is left, in editorial brackets {sc[2][:2]}')
pills = await page.evaluate("[...document.querySelectorAll('#nav .pill')].map(b => b.textContent)")
expect(any(t.startswith('Poems · 2 of') for t in pills) and pills[0].startswith("Today's garden") and any(t in ('Sound on', 'Sound off') for t in pills), f'the nav offers the poems, the daily garden and the sound {pills}')
await page.click('#scroll .entry .by button >> nth=1'); await page.wait_for_timeout(150)
v = await page.evaluate("[window.__samon.view, window.__samon.G.name]")
expect(v == ['puzzle', G[1]['name']], f"a garden's name in the scroll opens it {v}")
expect(len(DAILY) == 366 and all(e['world'] == 0 and e['intended'] and e['par_moves'] for e in DAILY), f'a year of daily gardens is embedded ({len(DAILY)})')
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
d = await page.evaluate("window.__samon.daily")
expect(d['year'] == len(DAILY) and d['key'] == '2026-10-04' and d['dayNo'] == 276 and not d['done'], f'the daily slot: day 276 of the year, not yet raked {d}')
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(250)
g = await page.evaluate("window.__samon.G")
day = DAILY[276 % len(DAILY)]
hdr = await page.evaluate("[document.getElementById('time').textContent, document.getElementById('gname').textContent, document.getElementById('next').textContent, document.getElementById('rakeDay').hidden]")
expect(g['rows'] == day['rows'] and hdr == ['Daily garden', '4 October 2026', 'Back to the gardens', True], f'today\'s garden is the year\'s day 276, under its date {hdr}')
await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(900)
st = await page.evaluate("[window.__samon.mode, document.getElementById('rakeDay').hidden, document.getElementById('status').textContent]")
expect(st[0] == 'draw' and not st[1] and 'press Rake' in st[2], f'a complete walk is not raked by itself here: it waits for Rake {st}')
await page.click('#rakeDay')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000)
await page.wait_for_function("window.__samon.poem.visible", timeout=5000)
d2 = await page.evaluate("window.__samon.daily")
st2 = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.poem.state, document.getElementById('seal').hidden]")
expect(d2['done'] and d2['streak'] == 1 and d2['turns'] == day['par'] and d2['kind'] == 'red' and 'Streak: 1 day' in st2[0] and st2[1] == ["Share today's score", 'Read the poem again'] and st2[2] == 'stay' and not st2[3],
f'raked at par: the record, a streak of one, the seal, the share action; the poem stays {d2} {st2}')
box = await page.evaluate("(() => { const r = document.getElementById('garden').getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()")
await page.mouse.click(*box); await page.wait_for_timeout(250); await page.mouse.click(*box); await page.wait_for_timeout(250)
st3 = await page.evaluate("[window.__samon.mode, document.getElementById('undo').disabled, document.getElementById('restart').disabled]")
expect(st3 == ['done', True, True], f'the raking is final: no walking back, Step back and Start over off {st3}')
await page.reload(); await page.wait_for_timeout(400)
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300)
st4 = await page.evaluate("[window.__samon.mode, document.getElementById('status').textContent, document.getElementById('seal').hidden, window.__samon.daily.streak, window.__samon.path.length]")
expect(st4[0] == 'done' and "Today's record" in st4[1] and not st4[2] and st4[3] == 1 and st4[4] == len(day['intended']), f'after a reload the day\'s raking and its record are back {st4[:4]}')
await page.evaluate("window.__samon.today = '2026-10-05T12:00:00'")
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300)
d3 = await page.evaluate("window.__samon.daily"); g3 = await page.evaluate("window.__samon.G.rows")
expect(d3['key'] == '2026-10-05' and not d3['done'] and d3['streak'] == 1 and g3 == DAILY[277 % len(DAILY)]['rows'] and await page.evaluate("window.__samon.mode") == 'draw', f'the next day is a fresh garden, and the streak counts yesterday {d3}')
nx = await page.evaluate("(() => { window.__samon.load(0); document.getElementById('next').click(); return window.__samon.G.name; })()")
expect(nx != d3['name'] and nx == G[1]['name'], f'the daily garden is never the next garden ({nx})')
await ctx.close()
# 12. drag intent: the walk follows the axis the hand moves on, not the cell under the pointer; the grip survives the walls
ctx = await b.new_context(viewport={'width': 700, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(200); await quiet(page)
gi = next(i for i, g in enumerate(G) if len(g['rows']) >= 6 and len(g['rows'][0]) >= 7 and g['gate'][2] == 'S' and g['gate'][0] == len(g['rows']) - 1
and set(g['rows'][-1] + g['rows'][-2]) == {'.'} and g['rows'][-3][g['gate'][1] + 3] == '.')
H, W = len(G[gi]['rows']), len(G[gi]['rows'][0]); gr, gc = G[gi]['gate'][:2]
async def at(r, c, dx=0.0, dy=0.0):
return await page.evaluate("""([r, c, dx, dy]) => { const cv = document.getElementById('garden'); const b = cv.getBoundingClientRect();
const s = b.width / cv.width; const g = window.__samon.geom();
return [b.left + (g.ox + (c + 0.5 + dx) * g.cs) * s, b.top + (g.oy + (r + 0.5 + dy) * g.cs) * s]; }""", [r, c, dx, dy])
async def walk():
return await page.evaluate("window.__samon.path.map(k => [Math.floor(k / window.__samon.G.W), k % window.__samon.G.W])")
async def begin():
await page.evaluate(f"window.__samon.load({gi}); window.__samon.setPath([]); document.getElementById('garden').scrollIntoView({{block: 'center'}})"); await page.wait_for_timeout(50)
x, y = await at(gr, gc); await page.mouse.move(x, y); await page.mouse.down()
run = [[gr, c] for c in range(gc, gc + 4)]
await begin(); x, y = await at(gr, gc + 3, 0, -0.9); await page.mouse.move(x, y, steps=40); await page.mouse.up()
expect(await walk() == run, f'a drag along the row whose hand drifts 0.9 of a cell into the next row still draws the row ({await walk()})')
await begin(); x, y = await at(gr, gc + 1, 0, 1.6); await page.mouse.move(x, y, steps=12); x, y = await at(gr, gc + 3, 0, 1.8); await page.mouse.move(x, y, steps=30); await page.mouse.up()
expect(await walk() == run, f'a pointer below the wall keeps its grip and the walk follows along the row ({await walk()})')
await begin(); x, y = await at(gr, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=40)
for k in range(1, 13): # a U-turn: a semicircle within the last cell of the run, up into the next row
a = math.pi / 2 - math.pi * k / 12
x, y = await at(gr, gc + 3, 0.1 + 0.35 * math.cos(a), -0.35 + 0.35 * math.sin(a)); await page.mouse.move(x, y)
x, y = await at(gr - 1, gc); await page.mouse.move(x, y, steps=40); await page.mouse.up()
expect(await walk() == run + [[gr - 1, c] for c in range(gc + 3, gc - 1, -1)], f'a U-turn drawn as an arc turns once and comes back along the next row ({await walk()})')
await begin(); x, y = await at(gr, gc + 3, -0.2, 0.05); await page.mouse.move(x, y, steps=30)
for k in range(1, 9): # a rounded corner from heading east to heading north
a = (math.pi / 2) * k / 8
x, y = await at(gr, gc + 3, -0.2 + 0.3 * math.sin(a), 0.05 - 0.3 + 0.3 * math.cos(a)); await page.mouse.move(x, y)
x, y = await at(gr - 2, gc + 3, 0.1, 0); await page.mouse.move(x, y, steps=30); await page.mouse.up()
expect(await walk() == run + [[gr - 1, gc + 3], [gr - 2, gc + 3]], f'a rounded corner is a run and a turn ({await walk()})')
await begin(); x, y = await at(gr, gc + 3, 0, -0.6); await page.mouse.move(x, y, steps=30); out = await walk()
x, y = await at(gr, gc, -0.2, -0.6); await page.mouse.move(x, y, steps=30); await page.mouse.up()
expect(out == run and await walk() == [[gr, gc]], f'dragging back along a drifted run retraces it ({out} → {await walk()})')
await begin()
for k in range(40): x, y = await at(gr, gc, 0.12 * math.cos(k), 0.12 * math.sin(k)); await page.mouse.move(x, y)
await page.mouse.up()
expect(await walk() == [[gr, gc]], f'a wobble in place takes no step ({await walk()})')
await ctx.close()
# 13. sound: nothing before a gesture; the synthesized sounds render; Sound off persists; the recordings carried in the
# page are found, credited on the scroll with their source, put down to follow a world's bell, and play
import struct
PIECES = json.load(open('/home/claude/zen/out/sound_pieces.json'))
ctx = await b.new_context(viewport={'width': 390, 'height': 900}, device_scale_factor=2, color_scheme='light')
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
s0 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.tracks]")
expect(s0 == [True, False, 'none', None], f'before any gesture there is no audio context, and the recordings are not looked at {s0}')
await page.click('#garden'); await page.wait_for_timeout(200)
s1 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.timers, window.__samon.sound.tracks.map(t => [t.title, t.src.slice(0, 22), t.dur])]")
expect(s1[:2] == [True, 'running'] and s1[2] in (6, 7) and s1[3] == [[p['title'], 'data:audio/mpeg;base64', p['dur']] for p in PIECES], f'a touch starts the audio, the six ambience loops (and, for a moment, the speaker\'s redraw), and finds the recordings in the page {s1}')
wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'drip', 0.3], [0.3, 'tock', 0.4], [0.7, 'tok', 0.4], [0.9, 'bell', 0.5]], 1.3)"))
pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:])
def peak(t0, t1): return max(abs(v) for v in pcm[int(t0 * 44100):int(t1 * 44100)]) / 32768
pk = [round(peak(*w), 3) for w in ((0.05, 0.15), (0.3, 0.4), (0.7, 0.75), (0.9, 1.2))]
expect(all(p > 0.03 for p in pk) and peak(0, 0.05) == 0, f'drip, pipe, seal and bell each render audibly, and nothing before the first {pk}')
wav = bytes(await page.evaluate("window.__samon.sound.render([[0.05, 'scrape', 1.2, 1], [1.5, 'crunch', 0.14], [1.8, 'birds', 0.1], [3.0, 'crickets', 0.08, 0.5], [3.7, 'wind', 0.3], [3.8, 'gust', 0.4, 1]], 5)"))
pcm = struct.unpack(f'<{(len(wav) - 44) // 2}h', wav[44:])
pk = [round(peak(*w), 3) for w in ((0.3, 1.1), (1.5, 1.6), (1.85, 2.9), (3.0, 3.5), (4.3, 5.0))]
expect(all(p > 0.015 for p in pk) and peak(1.44, 1.5) < 0.02, f'the rake\'s scrape, a footstep, a bird, a cricket and the wind each render audibly, and the scrape dies away when the rake stops {pk} (tail {peak(1.44, 1.5):.3f})')
dp = await page.evaluate("[5, 6, 12, 18, 19, 20, 23, 4].map(h => window.__samon.sound.dayPart(h))")
expect(dp == ['dusk', 'day', 'day', 'day', 'dusk', 'night', 'night', 'night'], f'birds from 6 to 19, crickets from 20 to 5, by the local clock {dp}')
await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended'])
await page.wait_for_function("window.__samon.mode === 'reveal'", timeout=3000)
sc1 = await page.evaluate("window.__samon.sound.scraping")
await page.wait_for_function("window.__samon.mode === 'done'", timeout=12000); await page.wait_for_timeout(100)
sc2 = await page.evaluate("window.__samon.sound.scraping")
expect(sc1 and not sc2, f'the scrape runs with the raking and stops with it ({sc1}, {sc2})')
await page.evaluate("window.__samon.sound.setVolume(0.3)"); await page.wait_for_timeout(300)
vs = await page.evaluate("[window.__samon.sound.vol, Math.round(window.__samon.sound.master * 100) / 100, localStorage.getItem('samon-sound-vol'), document.getElementById('soundVol') && document.getElementById('soundVol').value, !!document.getElementById('hapticsToggle'), window.__samon.sound.haptics]")
expect(vs[:3] == [0.3, 0.3, '0.3'] and vs[4] and vs[5], f'the volume is set, remembered and reaches the master gain; the haptics switch shows where the device can tick {vs}')
await page.click('#hapticsToggle'); await page.wait_for_timeout(100)
hp = await page.evaluate("[window.__samon.sound.haptics, localStorage.getItem('samon-haptics'), document.getElementById('hapticsToggle').textContent]")
expect(hp == [False, '0', 'Haptics off'], f'haptics can be turned off, and that is remembered {hp}')
s2 = await page.evaluate("[window.__samon.sound.ready, window.__samon.sound.state, window.__samon.sound.on]")
expect(s2 == [True, 'running', True], f'the live context is untouched by the offline render {s2}')
tm = await page.evaluate("(() => { const a = window.__samon.sound.timers; window.__samon.sound.piece('capstone'); return [a, window.__samon.sound.timers]; })()")
expect(tm[1] == tm[0] + 1, f'after a world\'s bell a piece is put down to follow {tm}')
await page.evaluate(f"window.__samon.sound.play(0, {PIECES[0]['dur'] - 12})"); await page.wait_for_function("window.__samon.sound.playing !== null", timeout=5000)
await page.wait_for_timeout(4000); lv = await page.evaluate("window.__samon.sound.level()")
expect(lv > 0.002, f'the recording is heard: the output meter reads {lv:.4f} while it plays (silence would mean the media source is muted)')
await page.evaluate("window.__samon.enterPoems()"); await page.wait_for_timeout(150)
cr = await page.evaluate("[document.getElementById('soundCredits').textContent, [...document.querySelectorAll('#soundCredits a')].map(a => [a.textContent, a.href])]")
expect(f"Shakuhachi, now and then: {PIECES[0]['title']}, {PIECES[0]['player']} ({PIECES[0]['license']})." in cr[0] and cr[1] == [[PIECES[0]['title'], PIECES[0]['source']]],
f'the scroll credits the recording, with its source {cr}')
await page.wait_for_function("window.__samon.sound.playing === null", timeout=20000)
expect(True, 'the piece plays to its end and is let go')
await page.click('#soundToggle'); await page.wait_for_timeout(150)
s3 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]")
expect(s3 == [False, 'suspended', 0, 'Sound off', '0'], f'Sound off suspends the context, clears the loops, and is remembered {s3}')
await page.reload(); await page.wait_for_timeout(300); await quiet(page); await page.click('#garden'); await page.wait_for_timeout(200)
s4 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.ready, document.getElementById('soundToggle').textContent]")
expect(s4 == [False, False, 'Sound off'], f'after a reload sound stays off, and a touch makes no context {s4}')
noslider = await page.evaluate("!document.getElementById('soundVol')")
await page.click('#soundToggle'); await page.wait_for_timeout(200)
s5 = await page.evaluate("[window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers, document.getElementById('soundVol').value, window.__samon.sound.vol]")
expect(s5[:2] == [True, 'running'] and s5[2] in (6, 7) and s5[3:] == ['30', 0.3] and noslider, f'Sound on starts it again, with its loops (drip, pipe, wind, gusts, birds, crickets) and the remembered volume; no slider while off {s5}')
# the mute button and the M key; what the page can tell of the browser's own silence
mb = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), b.title]; })()")
await page.click('#mute'); await page.wait_for_timeout(120)
mb2 = await page.evaluate("(() => { const b = document.getElementById('mute'); return [b.className, b.getAttribute('aria-pressed'), b.getAttribute('aria-label'), window.__samon.sound.on, window.__samon.sound.state, document.getElementById('soundToggle').textContent, localStorage.getItem('samon-sound')]; })()")
await page.keyboard.press('m'); await page.wait_for_timeout(200)
mb3 = await page.evaluate("[document.getElementById('mute').className, window.__samon.sound.on, window.__samon.sound.state, window.__samon.sound.timers > 0]")
expect(mb == ['mute', 'false', 'Mute', 'Mute (M)'] and mb2 == ['mute off', 'true', 'Unmute', False, 'suspended', 'Sound off', '0'] and mb3 == ['mute', True, 'running', True],
f'the speaker in the header mutes and unmutes, as does M, in step with the list\'s switch and the saved setting {mb} {mb2} {mb3}')
bl = await page.evaluate("(() => { navigator.getAutoplayPolicy = () => 'disallowed'; window.__samon.sound.renderMute(); const b = document.getElementById('mute'); const r = [window.__samon.sound.blocked, b.className, b.getAttribute('aria-label')]; delete navigator.getAutoplayPolicy; window.__samon.sound.renderMute(); return r.concat([b.className]); })()")
expect(bl == [True, 'mute blocked', 'Sound is blocked by the browser', 'mute'], f'when the browser says it will not play (Firefox\'s autoplay policy), the speaker says so in red, and recovers {bl}')
aud = await page.evaluate("[typeof navigator.audioSession, typeof navigator.mediaSession, !!document.getElementById('muteHint') || localStorage.getItem('samon-sound-hint') === '1']")
expect(aud[2], f'the first start of sound shows a one-time word beside the speaker (audioSession: {aud[0]}, mediaSession: {aud[1]})')
await ctx.close()
# 14. sharing: a raking is a code in the URL's hash (the walk as a number in the mixed radix of its legal moves), shown
# but not credited to whoever opens it; a player's whole progress is another code, offered for taking over; the share
# text draws the raking in box-drawing characters
ctx = await b.new_context(viewport={'width': 500, 'height': 900}, device_scale_factor=2, color_scheme='light', permissions=['clipboard-read', 'clipboard-write'])
page = await ctx.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
lens = []; bits = []; bad = []
for i in (range(len(G)) if not os.environ.get('SAMON_QUICK') else range(0, len(G), 7)):
g = G[i]; await page.evaluate(f"window.__samon.load({i})")
code = await page.evaluate("(w) => window.__samon.share.code(w)", g['intended'])
w = await page.evaluate("(w) => window.__samon.share.walk(w)", g['intended'])
back = await page.evaluate("(a) => window.__samon.share.unwalk(a[0], a[1])", [w[0], len(g['intended']) - 1])
h = await page.evaluate("(c) => { const h = window.__samon.share.read(c); return [h.i, h.grey, h.n.toString()]; }", code)
if not (code and back == g['intended'] and h == [i, False, w[0]]): bad.append(g['name'])
lens.append(len(code)); bits.append(w[1])
expect(not bad and max(lens) <= 18, f'every intended raking round-trips through its code: {len(lens)} gardens, {min(lens)}–{max(lens)} characters (mean {sum(lens) / len(lens):.1f}); the walks take {sum(bits) / len(bits):.1f} bits on average, against {sum(2 * (len(g["intended"]) - 1) for g in G) / len(G):.0f} at two bits a step {bad}')
lay = await page.evaluate("window.__samon.share.layouts()")
allh = lay['campaign'] + lay['daily']
expect(len(set(allh)) == len(allh) == len(G) + 366 and all(0 <= h < 2 ** 32 for h in allh), f'every garden, campaign or daily, has its own 32-bit layout hash ({len(set(allh))} of {len(allh)} distinct)')
legal = await page.evaluate("(() => { window.__samon.load(0); return window.__samon.share.walk([[2, 0], [2, 1], [1, 1]]); })()")
expect(legal is None, f'a walk that breaks a rule (onto a stone) has no code {legal}')
grid = await page.evaluate("(w) => window.__samon.share.text(w, 'red')", G[0]['intended'])
rows = grid.split('\n')
expect(rows[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and len(rows) == len(G[0]['rows']) + 3 and all(len(r) == len(G[0]['rows'][0]) + 2 for r in rows[1:])
and rows[1] == '┏━━━━┓' and rows[2] == '┃╶──┐┃' and rows[3] == '┃███│┃' and rows[4] == '┃┌──┘┃' and rows[-1] == '┗┷━━━┛', f'the share text: a heading, then the garden in box-drawing characters, the stroke in light lines through the gate in the heavy wall, stones as blocks\n{grid}')
i2 = next(i for i, g in enumerate(G) if g['end'] and g['end_side'] == 'E')
await page.evaluate(f"window.__samon.load({i2})")
grid2 = await page.evaluate("(w) => window.__samon.share.grid(w)", G[i2]['intended'])
r2 = grid2.split('\n')
expect(r2[G[i2]['end'][0] + 1].endswith('┨') and sum(1 for ch in grid2 if ch in '┨┠┯┷') == 2, f'a side gate is a second gap in the wall, where the stroke begins\n{grid2}')
# #r=: the raking is shown, with its seal, but recorded for nobody; Rake it yourself (or any tap) gives the viewer the garden fresh
gi = 5; g5 = G[gi]
await page.evaluate(f"window.__samon.load({gi})")
code = await page.evaluate("(w) => window.__samon.share.code(w)", g5['intended'])
await page.evaluate("localStorage.clear()")
await page.goto('about:blank'); await page.goto(URL + '#r=' + code); await page.wait_for_timeout(600); await quiet(page)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, window.__samon.turnsOf(), document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), document.getElementById('seal').hidden, document.getElementById('next').hidden, localStorage.getItem('samon-progress'), !!window.__samon.share.shared, document.getElementById('undo').disabled]")
expect(st[:4] == ['done', g5['name'], len(g5['intended']), g5['par']] and st[4] == f"[A raking shared with you: {g5['par']} turns, par {g5['par']}, the abbot's seal. Rake it yourself from the gate: a tap smooths the sand.]"
and st[5] == ['Rake it yourself', 'Share it on'] and not st[6] and st[7] and st[8] is None and st[9] and not st[10], f'a link to a raking opens that garden raked, with the seal, credited to nobody {st[:8]} {st[8:]}')
pts = await centers(page, [g5['intended'][3]]); await page.mouse.click(*pts[0]); await page.wait_for_timeout(300)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('status').textContent, location.hash, !!window.__samon.share.shared, document.getElementById('restore').hidden, document.getElementById('seal').hidden]")
expect(st == ['draw', g5['name'], 0, '[Now you: drag the monk in from the gate.]', '', False, True, True], f'a tap on the shared raking smooths it away: the garden is the viewer\'s, from the gate, with nothing to Restore, and the hash is gone {st}')
await page.goto(URL + '#r=' + code); await page.wait_for_timeout(400) # (a hash change on the open page shows it too)
st = await page.evaluate("[window.__samon.mode, !!window.__samon.share.shared]")
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300)
st2 = await page.evaluate("[window.__samon.mode, window.__samon.path.length, !!window.__samon.share.shared, location.hash]")
expect(st == ['done', True] and st2 == ['draw', 0, False, ''], f'the same by Rake it yourself, after the link is followed on the open page {st} {st2}')
gcode = await page.evaluate("(w) => window.__samon.share.code(w, true)", g5['intended'])
await page.goto(URL + '#r=' + gcode); await page.wait_for_timeout(400)
st = await page.evaluate("[document.getElementById('seal').className, document.getElementById('status').textContent.includes('his seal in grey')]")
expect(st == ['seal grey', True], f'a raking made with the abbot\'s help shows his grey seal {st}')
# a daily garden's raking: of another day, the viewer is sent on to today's; of today, raked at par, it is shown and then today's is theirs to rake
DAILY = json.load(open('/home/claude/zen/out/daily_gardens.json'))
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200)
day = DAILY[276 % len(DAILY)]
dcode = await page.evaluate("(w) => window.__samon.share.code(w)", day['intended'])
await page.goto('about:blank'); await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(600); await quiet(page)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('gname').textContent, document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), window.__samon.daily.key]")
expect(st[:3] == ['done', 'daily:2026-10-04', '4 October 2026'] and 'of the daily garden of 4 October 2026' in st[3] and "Today's garden is another" in st[3] and st[4][0] == "Today's garden" and st[5] == '2026-10-04',
f'a shared raking of an earlier day\'s garden is shown under its date, and offers today\'s instead {st}')
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, location.hash, window.__samon.daily.done]")
expect(st[0] == 'draw' and st[1].startswith('daily:') and st[1] != 'daily:2026-10-04' and st[2] == 0 and st[3] == '' and not st[4], f'Today\'s garden leaves it for today\'s, unraked {st}')
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
await page.goto(URL + '#r=' + dcode); await page.wait_for_timeout(400)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, document.getElementById('status').textContent.includes('Rake it yourself'), [...document.querySelectorAll('#actions button')].map(b => b.textContent)[0]]")
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(300)
st2 = await page.evaluate("[window.__samon.mode, window.__samon.G.name, window.__samon.path.length, document.getElementById('rakeDay').hidden]")
expect(st == ['done', 'daily:2026-10-04', True, 'Rake it yourself'] and st2 == ['draw', 'daily:2026-10-04', 0, True], f'the same day\'s raking, shared, is shown and then left for the viewer to rake {st} {st2}')
# the share text and link: the share action after a raking, to the clipboard where there is no share sheet
await page.evaluate("window.__samon.load(0)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[0]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
acts = await page.evaluate("[...document.querySelectorAll('#actions button')].map(b => b.textContent)")
await page.click('#actions button:has-text("Share this raking")'); await page.wait_for_timeout(400)
clip = await page.evaluate("navigator.clipboard.readText()")
st = await page.evaluate("document.getElementById('status').textContent")
cl = clip.split('\n')
expect(acts == ['Share this raking'] and cl[0] == f"Samon · Dawn 1 · {G[0]['name']} · {G[0]['par']} turns, par {G[0]['par']} · 静" and cl[1:-1] == grid.split('\n')[1:] and cl[-1].startswith(URL + '#r=') and st.startswith('[Copied: the raking drawn in lines, with a link that opens it'),
f'after a raking, Share this raking copies the heading, the drawing and a link that opens the raking {cl[0]!r} {cl[-1][-30:]!r} {st!r}')
await page.goto('about:blank'); await page.goto(cl[-1]); await page.wait_for_timeout(600)
st = await page.evaluate("[window.__samon.mode, window.__samon.G.name, !!window.__samon.share.shared, window.__samon.turnsOf()]")
expect(st == ['done', G[0]['name'], True, G[0]['par']], f'and that link shows it {st}')
# #p=: progress as a code; the link offers it, Take it over joins it with what is here (whichever is better stands)
await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
for i in (0, 1, 2):
await page.evaluate(f"window.__samon.load({i})"); await page.evaluate("(w) => window.__samon.setPath(w)", G[i]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'; window.__samon.pickDaily()"); await page.wait_for_timeout(200)
await page.evaluate("(w) => window.__samon.setPath(w)", day['intended']); await page.wait_for_timeout(300); await page.click('#rakeDay')
await page.wait_for_function("window.__samon.mode === 'done'", timeout=8000)
await page.evaluate("window.__samon.load(3)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[3]['intended'][:4]); await page.wait_for_timeout(100)
await page.evaluate("window.__samon.askAbbot()"); await page.wait_for_timeout(500) # (help on the unfinished walk travels with it; the save is debounced)
pcode = await page.evaluate("window.__samon.share.progress()")
mine = json.loads(await page.evaluate("localStorage.getItem('samon-progress')"))
expect(len(pcode) < 120 and mine[G[3]['name']].get('helped') and len(mine[G[3]['name']]['draft']) == 4, f'the whole progress is a code of {len(pcode)} characters {pcode}')
await page.evaluate("localStorage.clear()"); await page.goto('about:blank')
await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
await page.evaluate(f"window.__samon.load(1)"); await page.evaluate("(w) => window.__samon.setPath(w)", G[1]['intended'][:3]); await page.wait_for_timeout(400) # (something of its own here: an unfinished walk on garden 2)
await page.evaluate("window.__samon.today = '2026-10-04T12:00:00'")
await page.goto(URL + '#p=' + pcode); await page.wait_for_timeout(500)
st = await page.evaluate("[document.getElementById('status').textContent, [...document.querySelectorAll('#actions button')].map(b => b.textContent), localStorage.getItem('samon-progress')]")
expect(st[0] == "[This link carries a player's progress: 3 of 148 gardens raked, 3 sealed, 1 daily garden, and an unfinished walk. Take it over here? It joins what is here, and whichever is better stands.]" and st[1] == ['Take it over', 'Leave it'] and G[0]['name'] not in (st[2] or ''),
f'a progress link is offered, not taken, with what it holds {st[:2]}')
tt = await page.evaluate("[...document.querySelectorAll('.veranda button, .assists button, .assists .t, #nav button, #nav .more, #nav input, #actions button, #mute, #orig, .count span, #where, .brand, #seal, .facts')].filter(e => !e.title).map(e => e.id || e.className || e.textContent)")
expect(not tt, f'every control, count and mark has a tooltip (gwern.net wants one wherever a hover could want a word more) {tt}')
await page.click('#actions button >> nth=0'); await page.wait_for_timeout(400)
got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')"))
st = await page.evaluate("[document.getElementById('status').textContent, location.hash, window.__samon.G.name, window.__samon.path.length, window.__samon.helped, [...document.querySelectorAll('#nav .pip.sealed')].length, window.__samon.daily.done, window.__samon.daily.turns, window.__samon.daily.streak]")
expect(all(got[G[i]['name']] == mine[G[i]['name']] for i in (0, 2)) and got[G[1]['name']]['done'] and got[G[1]['name']]['sealed'] and len(got[G[1]['name']]['draft']) == 3
and got[G[3]['name']]['draft'] == mine[G[3]['name']]['draft'] and got[G[3]['name']].get('helped') and got['daily:2026-10-04']['path'] == mine['daily:2026-10-04']['path'] and got['daily:2026-10-04']['turns'] == day['par'] and got['daily:2026-10-04']['kind'] == 'red'
and st[0].startswith('[Taken over: 3 of 148 gardens raked') and st[1] == '' and st[2] == G[3]['name'] and st[3] == 4 and st[4] and st[5] == 3 and st[6] and st[7] == day['par'] and st[8] == 1,
f'Take it over: the rakings, the seals, the day\'s record with its walk, and the unfinished walk with its help arrive, and the walk of its own here is kept {st}')
await page.evaluate("window.__samon.pickDaily()"); await page.wait_for_timeout(300)
st = await page.evaluate("[window.__samon.mode, window.__samon.path.length, document.getElementById('status').textContent.includes(\"Today's record\")]")
expect(st == ['done', len(day['intended']), True], f'the carried day\'s raking shows as the record {st}')
await page.click('#actions button:has-text("Share today\'s score")'); await page.wait_for_timeout(400)
cl = (await page.evaluate("navigator.clipboard.readText()")).split('\n')
shape = await page.evaluate("window.__samon.share.grid([])")
expect(cl[0] == f"Samon · daily garden 2026-10-04 · {day['par']} turns, par {day['par']} · 静 · streak 1" and cl[1:-1] == shape.split('\n') and cl[-1] == URL + '#daily'
and not any(ch in shape for ch in '─│┌┐└┘╴╵╶╷') and shape.count('░') == sum(r.count('.') for r in day['rows']),
f'the daily garden shares the score and the garden\'s shape, without the stroke, and a link to today\'s garden {cl[0]!r} {cl[-1][-8:]!r}')
await page.evaluate("window.__samon.load(0)"); await page.goto(URL + '#daily'); await page.wait_for_timeout(400)
st = await page.evaluate("[window.__samon.G.name, location.hash]")
expect(st == ['daily:2026-10-04', ''], f'#daily opens today\'s garden {st}')
await page.click('#carry'); await page.wait_for_timeout(400)
cl = await page.evaluate("navigator.clipboard.readText()"); st = await page.evaluate("document.getElementById('status').textContent")
expect(cl.startswith(URL + '#p=') and st.startswith('[Copied a link that carries your progress'), f'Carry my progress copies such a link {cl[:60]!r} {st!r}')
await page.goto('about:blank'); await page.goto(URL + '#r=AAAA'); await page.wait_for_timeout(500)
st = await page.evaluate("[document.getElementById('status').textContent, document.getElementById('status').className, location.hash, window.__samon.mode]")
expect(st == ["[The link's code can't be read: it was made by another edition of the game.]", 'status warn', '', 'draw'], f'a code the page can\'t read is said so, and dropped {st}')
await page.goto(URL + '#p=not a code'); await page.wait_for_timeout(300)
st = await page.evaluate("[document.getElementById('status').textContent, location.hash]")
expect(st == ["[The link's code can't be read: it is not a Samon code.]", ''], f'so is a string that is no code at all {st}')
# durability (Astra's points): a garden is named by a 32-bit layout hash, not its place, so a code survives a reordering of the
# campaign and a changed garden is refused, never mistaken for the one meant
B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
await page.evaluate("localStorage.clear()"); await page.goto('about:blank'); await page.goto(URL); await page.wait_for_timeout(300); await quiet(page)
await page.evaluate("window.__samon.load(5)")
code5 = await page.evaluate("(w) => window.__samon.share.code(w)", G[5]['intended'])
bits = ''.join(format(B64.index(ch), '06b') for ch in code5)
bent = bits[:5] + ('1' if bits[5] == '0' else '0') + bits[6:] # one bit of the layout hash
bent = ''.join(B64[int(bent[i:i + 6], 2)] for i in range(0, len(bent), 6))
await page.goto(URL + '#r=' + bent); await page.wait_for_timeout(400)
st = await page.evaluate("[document.getElementById('status').textContent, window.__samon.mode, location.hash]")
expect(st == ["[The link's code can't be read: its garden is not in this edition, or has changed since.]", 'draw', ''], f'a code whose garden hash matches no garden is refused, not shown over another garden {st}')
await page.evaluate("(w) => window.__samon.setPath(w)", G[5]['intended'])
await page.wait_for_function("['reveal', 'done'].includes(window.__samon.mode)", timeout=3000)
await page.focus('#garden'); await page.keyboard.press('Enter'); await page.wait_for_function("window.__samon.mode === 'done'", timeout=5000)
pcode5 = await page.evaluate("window.__samon.share.progress()")
await page.evaluate("window.__samon.share.swap(5, 9); window.__samon.load(0)") # the campaign reordered under the links
await page.goto(URL + '#r=' + code5); await page.wait_for_timeout(400)
st = await page.evaluate("[window.__samon.G.name, window.__samon.mode, !!window.__samon.share.shared, [...document.querySelectorAll('#nav .pip')].findIndex(b => b.getAttribute('aria-current') === 'true')]")
expect(st == [G[5]['name'], 'done', True, 9], f'after the campaign is reordered, a raking link still opens the garden it was made in, now in its new place {st}')
await page.evaluate("localStorage.clear()")
await page.goto(URL + '#p=' + pcode5); await page.wait_for_timeout(400)
await page.click('#actions button:has-text("Take it over")'); await page.wait_for_timeout(400)
got = json.loads(await page.evaluate("localStorage.getItem('samon-progress')"))
expect(got.get(G[5]['name'], {}).get('sealed') and G[9]['name'] not in got, f'and carried progress lands on the garden it was earned in, not on the one now at its index {sorted(got)}')
await page.evaluate("window.__samon.share.swap(5, 9)")
await ctx.close()
await b.close()
real = [e for e in errors if 'ERR_TUNNEL' not in e and 'Failed to load resource' not in e]
expect(not real, f'no page errors {real}')
print('FAILS:', fails)
asyncio.run(main())
The poems (poems/)
poems/originals-brainstorm.md
# Original candidates for three gardens (not in the game)
Written by Claude, 2026-10-04, for the three gardens whose poems are weak and for which no better classical poem turned up.
None is in the game: the design's claim is that every word a player reads is a real poem, so these wait for a decision.
If one goes in, `translation_credit` should say "Original, Claude 2026", `japanese` should be empty (the 原文 button then has nothing to show; the page hides it), and the Presentation paragraph loses "all real".
## Carp (W2 Morning; 7×7 open sand, one ripple stone low on the right; extra)
Current: Bashō, 蛸壺やはかなき夢を夏の月 ("Octopus traps— / fleeting dreams / under the summer moon"): a great poem, a forced pairing (octopus pots for a carp pond, night for a morning world). No classical carp poem could be found and verified: the two Shiki carp verses Astra chose could not be confirmed, and the Buson, Issa and Bashō lists checked have none.
1. Morning light— / the carp sinks, / the ring stays. **(pick)**
2. One ring widening— / the carp that made it / is somewhere else.
3. The carp rises / to nothing: / a ring for its own sake.
4. Under the lily pad / the carp keeps its circle / without moving.
5. Rain over— / the carp's one ring / crosses all the others.
6. Red under green water: / a carp, and the ring / it leaves me.
7. The carp's mouth / opens at the surface— / one O, then none.
8. The whole pond / for one ring: / the carp's morning.
9. Sun on the pond— / the carp's shadow / wider than the carp.
10. Deep down / the carp holds still; / the ring goes on without it.
11. A carp turns / under the one place / the water remembers it.
12. Fed at dawn, / the carp returns / the same ring each day.
Pick: 1. Seven words; the ring outlasts what made it, which is the ripple stone's rule (eight cells raked as one ring) and the game's premise (the footprints go, the raking stays); morning, as the world is.
## Go board (W4 Afternoon; 7×7, eleven pebbles; extra)
Current: Shōtetsu, 白玉かなにぞととへば萩のうへの影はこたへずふるさとの月 ("White jewels— / or what? I ask; / the light upon the bush clover / gives no answer: / moon of my old home"): obscure, and the board is only in the question. No classical poem about go stones turned up.
1. Set down one by one, / the stones already / drew the line. **(pick)**
2. Black stone, white stone: / each knows one thing / and says it.
3. Eleven stones— / the game was decided / before the first was placed.
4. On the board / every stone is a question / the line answers.
5. The board is empty / where it matters; / the stones only point.
6. Pale stones go straight; / dark stones turn; / nothing else speaks.
7. Afternoon— / the stones have no color / until the line passes.
8. Each stone / holds the line / for one move.
9. The master left / and the stones / kept playing.
10. Nobody wins here: / the line / simply closes.
11. Eleven pebbles / and one way / between them.
12. The go stones / have forgotten the game / and remember the path.
Pick: 1. It is how the pebbles were made (chosen one at a time, each cutting the rakings left) and what they do (together they fix the only raking), without saying either.
## Latch (W6 Dusk; 7×7, six pebbles and a side gate; combination)
Current: Rengetsu, むれ立て 人くと見しは 秋風に 尾花が袖の なびく也けり ("I thought a crowd / was coming toward me— / only the sleeves / of pampas grass / waving in the autumn wind"): a stock conceit (grass mistaken for people), tied to the garden only as "expectation at a boundary". The gate poems that would fit are all in use (Issa's moon at the gate, Bashō's tide at the gate and moon as guide, Buson's sweetfish, Buson's leaving by the gate, Ryōkan's thief).
1. Not locked, / only shut: / the side gate at dusk. **(pick)**
2. The latch lifts / from the other side— / nobody there but dusk.
3. Two gates, one latch: / the wind uses / whichever is open.
4. Evening— / a latch I never set / clicks in the wind.
5. I came in / by the small gate; / the big one was only painted.
6. The latch is cold. / Beyond it, / the same garden.
7. Dusk at the side gate: / the latch remembers / a hand.
8. Lift the latch— / the path was waiting / on the inside.
9. The latch drops / behind me; / the sand ahead unmarked.
10. Someone before me / oiled the latch: / it opens without a sound.
11. A small gate, / a small latch, / the whole garden behind it.
12. Dusk— / the latch warm / from the last hand.
Pick: 1. The side gate is an entrance the player must use, not a barrier; the poem says only that, at the world's hour.
poems/pass2_replacements.py — Second curation pass (2026-10-04): replace the weakest pairings with better classical poems, each checked against a
#!/usr/bin/env python3
"""Second curation pass (2026-10-04): replace the weakest pairings with better classical poems, each checked against a
page that prints it. Applied once to poems/samon-poems.json; kept for the record (about.revisions lists every swap)."""
import json
PATH = '/home/claude/zen/poems/samon-poems.json'
ASTRA = '/home/claude/zen/poems/astra-samon-poem-selection.json'
TODAY = '2026-10-04'
D = json.load(open(PATH))
A = json.load(open(ASTRA))
P = D['poems']
ITOYO = 'https://www2.yamanashi-ken.ac.jp/~itoyo/basho/'
BASHO = ('松尾芭蕉', '1644–1694')
CLAUDE = f'New translation by Claude, {TODAY}'
def basho(pid, ja, en, collection, display, url, label, notes, number=None):
return dict(id=pid, poet='Matsuo Bashō', author_japanese=BASHO[0], author_dates=BASHO[1], form='haiku', japanese=ja,
translation=en, translation_credit=CLAUDE, collection=collection, poem_number=number, source_url=url,
source_label=label, display_source=display, notes=notes,
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url')
NEW = {
'new-basho-sweep-garden': basho('new-basho-sweep-garden', '庭掃て出ばや寺に散柳',
"Let me sweep the garden\nbefore I go—\nwillow leaves fall in the temple.",
'おくのほそ道 (Oku no hosomichi), Zenshōji, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno34.htm',
'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 曾良との別れ',
'Leaving the temple where he had lodged, Bashō sweeps its garden as a guest\'s thanks; the young monks had run after '
'him with paper and inkstone. The 1903 Zenshū prints 精舍 for 寺.'),
'new-basho-moon-sand': basho('new-basho-moon-sand', '月清し遊行のもてる砂の上',
"The moon is clear\non the sand\nthe pilgrim monk carried.",
'おくのほそ道 (Oku no hosomichi), Tsuruga (Kehi shrine), journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno38.htm',
'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 敦賀',
'遊行 is the Yugyō Shōnin, head of the Ji sect: the second, Ta\'a, carried sand himself to fill the shrine\'s muddy '
'grounds, and his successors keep the rite (遊行の砂持ち). The 1903 Zenshū prints 月淸し遊行の持てる砂の上.'),
'new-basho-hikarido': basho('new-basho-hikarido', '五月雨の降のこしてや光堂',
"The summer rains—\ndid they spare only this?\nThe Hall of Light.",
'おくのほそ道 (Oku no hosomichi), Hiraizumi, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno20.htm',
'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 平泉',
'The Konjikidō at Chūsonji, kept whole for five centuries while everything around it fell; 降りのこす is to leave '
'something unrained-on.'),
'new-basho-ishiyama': basho('new-basho-ishiyama', '石山の石より白し秋の風',
"Whiter\nthan the stones of Stone Mountain—\nthe autumn wind.",
'おくのほそ道 (Oku no hosomichi), Natadera, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno33.htm',
'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 那谷',
'Autumn\'s color is white in the five-phase scheme; the wind outdoes the white rocks of Nata (or of Ishiyama) in it.'),
'new-basho-one-field': basho('new-basho-one-field', '田一枚植て立去る柳かな',
"One field planted—\nand I leave\nthe willow.",
'おくのほそ道 (Oku no hosomichi), the Yugyō willow, journey of 1689', 'Oku no hosomichi', ITOYO + 'okunohosomichi/okuno07.htm',
'Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 遊行柳',
'Under Saigyō\'s willow; who plants the field (the women, or Bashō\'s party) is left open, as is who leaves.'),
'new-basho-hida': basho('new-basho-hida', '涼しさを飛騨の工が指図かな',
"Coolness—\ndrawn to the plan\nof a Hida carpenter.",
'Letter to Sugiyama Sanpū, Genroku 7, intercalary fifth month, day 11 (1694)', 'letter of 1694', ITOYO + 'letter/sanpu1.htm',
'Yamanashi Prefectural University, Bashō database: letter to Sanpū (1)',
'指図 is an architect\'s drawing or direction; the carpenters of Hida were the proverbial master builders. Bashō '
'later preferred the variant 涼しさの指図に見ゆる住まゐかな.'),
'new-basho-sea-darkens': basho('new-basho-sea-darkens', '海くれて鴨のこゑほのかに白し',
"The sea darkens;\nthe voices of the ducks\nfaintly white.",
'野ざらし紀行 (Nozarashi kikō), Atsuta, winter 1684', 'Nozarashi kikō', ITOYO + 'nozarasi/nozara23.htm',
'Yamanashi Prefectural University, Bashō database: Nozarashi kikō, 海邊に日暮して',
'Headnote 海邊に日暮して. A sound given a color: the source calls it one of his finest verses.'),
'new-basho-summer-room': basho('new-basho-summer-room', '山も庭も動き入るゝや夏座敷',
"Mountain and garden\ncome moving in—\nthe summer room.",
'芭蕉俳句全集 (1903), 夏・夏座敷; composed 1689 at Kurobane', 'Kurobane, 1689', 'https://ja.wikisource.org/wiki/芭蕉俳句全集',
'Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), summer section',
'A greeting verse for his host\'s open summer room at Kurobane, on the Oku no hosomichi journey: the view enters the room.'),
'new-ryokan-new-pond': dict(id='new-ryokan-new-pond', poet='Ryōkan', author_japanese='良寛', author_dates='1758–1831', form='haiku',
japanese='新池や蛙とびこむ音もなし', translation="The new pond—\na frog jumps in;\nnot a sound.", translation_credit=CLAUDE,
collection='Ryōkan zenkushū (ed. Tanikawa Toshirō), as cited by the source', poem_number=None,
source_url='https://sakuramitih31.livedoor.blog/archives/41933640.html', additional_source_url='https://idea1616.com/ryokan-haiku/',
source_label='名歌鑑賞: 新池や蛙とびこむ音もなし (blog, citing 谷川敏朗『良寛全句集』)', display_source='',
notes='Ryōkan\'s answer to Bashō\'s old pond: the new pond has no sound. Attribution is the traditional one; the text '
'was checked against two secondary pages, not a critical edition.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (secondary sources)'),
'new-ryokan-thief-moon': dict(id='new-ryokan-thief-moon', poet='Ryōkan', author_japanese='良寛', author_dates='1758–1831', form='haiku',
japanese='盗人に取り残されし窓の月', translation="The thief\nleft it behind:\nthe moon in the window.", translation_credit=CLAUDE,
collection='Ryōkan\'s hokku; the anecdote of the thief at his hut', poem_number=None,
source_url='https://nichi-zen.site/remaining-moon/', additional_source_url='https://www.jomo-news.co.jp/articles/-/5483',
source_label='日常実践の禅: 盗人に取り残されし窓の月', display_source='',
notes='The thief found nothing worth taking (one telling has Ryōkan roll over so the man could take his bedding). '
'Attribution is the traditional one; checked against secondary pages.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (secondary sources)'),
'new-buson-dew-thorns': dict(id='new-buson-dew-thorns', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku',
japanese='白露や茨の刺にひとつづつ', translation="White dew—\non each thorn of the bramble,\none drop.", translation_credit=CLAUDE,
collection='蕪村句集 (Buson kushū), autumn', poem_number=None,
source_url='https://idea1616.com/buson/', additional_source_url='https://ameblo.jp/mtada99/entry-12474301599.html',
source_label='与謝蕪村の俳句 102選 (list), and a commentary page', display_source='Buson kushū',
notes='Checked against two secondary pages (the museum and bonjin lists used elsewhere do not carry it).',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (secondary sources)'),
'new-buson-dry-spring': dict(id='new-buson-dry-spring', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku',
japanese='柳ちり清水かれ石ところどころ', translation="Willow leaves fallen,\nthe spring run dry,\nstones here and there.", translation_credit=CLAUDE,
collection='蕪村集 (as indexed by the museum)', poem_number=None,
source_url='https://www2.city.kurashiki.okayama.jp/musnat/plant/bungakusakuhin/busonsyuu.htm',
source_label='Kurashiki Museum of Natural History: 蕪村集の植物', display_source='',
notes='An answer to Saigyō\'s roadside willow and its clear water (Stepping stone): the water gone, the stones left.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'),
'new-buson-fuji-leaves': dict(id='new-buson-fuji-leaves', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku',
japanese='不二ひとつうづみ残して若葉かな', translation="Young leaves—\nthey have buried everything\nbut Fuji.", translation_credit=CLAUDE,
collection='蕪村句集 (Buson kushū), summer', poem_number=None,
source_url='https://bonjin-ultra.com/buson.htm', source_label='bonjin-ultra: 蕪村の俳句, summer', display_source='Buson kushū',
notes='うづみ残す: to leave one thing unburied.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'),
'new-issa-snail': dict(id='new-issa-snail', poet='Kobayashi Issa', author_japanese='小林一茶', author_dates='1763–1828', form='haiku',
japanese='かたつぶりそろそろ登れ富士の山', translation="Little snail,\nslowly, slowly climb—\nMount Fuji.", translation_credit=CLAUDE,
collection='一茶発句集 (Issa hokkushū), Bunsei edition (1829, posthumous)', poem_number=None,
source_url='https://crd.ndl.go.jp/reference/entry/index.php?page=ref_view&id=1000180048',
source_label='NDL Collaborative Reference Database: the collections that carry the snail verse', display_source='Issa hokkushū (1829)',
notes='Not in Issa\'s own diaries; first printed two years after his death (the Kaei edition has 不二の山). Text as the '
'Bunsei edition prints it, per the reference record.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'),
'new-tomonori-hi33': dict(id='new-tomonori-hi33', poet='Ki no Tomonori', author_japanese='紀友則', author_dates='d. c. 905', form='waka',
japanese='ひさかたの 光のどけき 春の日に しづ心なく 花の散るらむ',
translation="In the tranquil light\nof a spring day\nunder the wide sky—\nwhy, with no calm in their hearts,\ndo the blossoms fall?",
translation_credit=CLAUDE, collection='Ogura Hyakunin Isshu; Kokin Wakashū 84', poem_number=33,
source_url='https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/33/', source_label='Bunka Net n.d., Hyakunin Isshu 33',
display_source='Hyakunin isshu 33', notes='しづ心 is a calm, settled heart; the garden Calm scores exactly that.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'),
'new-manyo-1068': dict(id='new-manyo-1068', poet='Kakinomoto no Hitomaro', author_japanese='柿本人麻呂歌集', author_dates='fl. c. 680–700', form='waka',
japanese='天の海に雲の波立ち月の船星の林に榜ぎ隠る見ゆ',
translation="On the sea of heaven\nthe waves of cloud rise;\nthe boat of the moon\nrows into the forest of stars\nand is hidden from sight.",
translation_credit=CLAUDE, collection='Man\'yōshū, book 7 (from the Hitomaro Collection)', poem_number=1068,
source_url='https://www.asahi-net.or.jp/~sg2h-ymst/manyok/manyok07.html',
source_label='訓読万葉集 巻7 (Yamato Uta), after Kamochi Masazumi\'s 萬葉集古義', display_source='Man\'yōshū 1068',
notes='Headed 天を詠める and marked as from the Kakinomoto no Hitomaro Collection, whose poems are not all his; the '
'source prints 榜ぎ for 漕ぎ.',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url'),
}
# Shikishi's bamboo window, from Astra's unused candidates (its page was checked today)
sk = dict(A['poems']['waka-other-11'])
sk.update(author_japanese='式子内親王', author_dates='d. 1201', poem_number=256, collection='Shin Kokin Wakashū',
display_source='Shinkokinshū 256',
translation_credit=f'Working translation by GPT-6 Astra (2026); reviewed by Claude, {TODAY}',
reverified=f'yes, {TODAY}: Japanese text and attribution checked against source_url (新古256, headnote 百首の歌たてまつりし時)')
NEW['waka-other-11'] = sk
SWAPS = [ # garden, new poem id, why
('The corridor', 'new-basho-sweep-garden',
'The first poem of the game is the chore itself: a guest sweeps the temple garden before going on. Rengetsu\'s dream-path (footprints that vanish) was good, but this is the premise.'),
('Bamboo', 'waka-other-11',
'Rengetsu\'s young bamboo was a felicitation (count its lucky joints, a thousand years): polite and empty. Shikishi\'s bamboo rustles at a window and cuts a doze short.'),
('Calm', 'new-tomonori-hi33',
'The dust-of-the-heart swept by mountain wind is a cliché. Tomonori\'s tranquil spring light names the garden\'s score, a calm heart (しづ心), by asking why the petals lack it.'),
('Plain stone', 'new-ryokan-new-pond',
'The grass hut was a forced pairing. Ryōkan\'s new pond, where the frog makes no sound, is the garden before the ripple stone; Bashō\'s old pond follows with the stone and its ring.'),
('Tatami', 'new-basho-summer-room',
'Rengetsu\'s mat of flowers is a conceit. Bashō\'s mountain and garden move into the summer room: the garden becomes a floor to sit on.'),
('The master\'s grooves', 'new-basho-hida',
'Shōtetsu\'s "learn the shape of the sixth-month wind" is didactic and obscure in English. Bashō\'s coolness drawn to a master carpenter\'s plan is the abbot\'s grooves exactly: a drawing laid down by a better hand.'),
('The finished ring', 'new-basho-hikarido',
'Rengetsu\'s sacred rope was serviceable. The Hall of Light that the summer rains left untouched is the one thing spared when everything around it was washed over, which is what the finished ring asks of the rake.'),
('His pebbles', 'new-buson-dew-thorns',
'Eifukumon\'in\'s dew overflowing the grass is diffuse. Buson\'s one drop on each thorn is a mark per point: the pebbles.'),
('Closed circle', 'new-buson-fuji-leaves',
'Rengetsu\'s bridge of blossoms, not to be stepped on, was apt but minor. Young leaves that bury everything but Fuji leave one shape standing in a covered field, as the raking leaves the ring.'),
('Pivot', 'new-basho-ishiyama',
'Shōtetsu\'s mountain that looks nearer after rain had no hold on the garden. The wide rake can turn only against stones; Bashō\'s autumn wind, whiter than the white stones it blows across, is the stroke among its pivots.'),
('Coil', 'new-issa-snail',
'Shōtetsu\'s insect voices thinner than thread were there for atmosphere. The snail carries the coil on its back and climbs Fuji at the pace the wide rake imposes.'),
('Long rake', 'new-basho-one-field',
'Shōtetsu\'s ox at dusk was filler. One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is.'),
('Turning stone', 'new-buson-dry-spring',
'Shōtetsu\'s settling snow was fine but minor. Buson\'s dried-up spring with stones here and there is a dry landscape, the garden\'s own genre, and answers Saigyō\'s willow and clear water earlier in the campaign.'),
('Bolt', 'new-ryokan-thief-moon',
'Teika\'s tied hunting robe was fastened to the title by a pun. The thief who takes everything but the moon in the window is the poem about what a bolt cannot keep in or out.'),
('Stargazer', 'new-manyo-1068',
'Shōtetsu\'s moon in a green spring sky has no stars. The Man\'yōshū moon-boat rowing into the forest of stars is the oldest and best star poem in the language, and the ripple stone is its one boat.'),
('Moonlit sea', 'new-basho-sea-darkens',
'Shōtetsu\'s wind seen in lifted mist was abstract. Bashō\'s sea darkening while the ducks\' voices turn faintly white is a night shore heard rather than seen.'),
('Owl\'s hour', 'teika-21',
'Teika\'s looking back over everything and asking the moon to light the road ahead moves here from the capstone: a sleepless hour before dawn is where that poem happens.'),
('Silver sand', 'new-basho-moon-sand',
'The capstone of the main path now gets the one classical poem about raked temple sand: the moon clear on the sand a pilgrim monk carried. Teika\'s farewell poem, good as it was here, goes to Owl\'s hour.'),
]
removed = {}
for garden, pid, why in SWAPS:
old = D['gardens'][garden]
removed[old] = P[old]
D['gardens'][garden] = pid
if pid in NEW:
P[pid] = NEW[pid]
# pairings: the positive half only (the comparison belongs in the revision log)
POS = {
'The corridor': 'The first poem of the game is the chore itself: a guest sweeps the temple garden before going on, as the player rakes each garden and moves on.',
'Bamboo': 'Bamboo leaves worried by wind at a window, cutting a doze short.',
'Calm': 'Tranquil spring light, and petals that fall without a calm heart (しづ心): the quality the garden scores, named by its absence.',
'Plain stone': 'Ryōkan\'s new pond, where the frog makes no sound: the garden before the ripple stone. Bashō\'s old pond follows, with the stone and its ring.',
'Tatami': 'Mountain and garden move into the summer room: the garden becomes a floor to sit on.',
'The master\'s grooves': 'Coolness drawn to a master carpenter\'s plan: the abbot\'s grooves are a drawing laid down by a better hand, which the rake follows.',
'The finished ring': 'The Hall of Light that the summer rains left untouched: the one thing spared when everything around it is washed over, which is what the finished ring asks of the rake.',
'His pebbles': 'One drop of dew on each thorn: a mark per point, as the abbot\'s grooves become pebbles.',
'Closed circle': 'Young leaves bury everything but Fuji: one shape left standing in a covered field, as the raking leaves the ring.',
'Pivot': 'The wide rake turns only against stones; Bashō\'s autumn wind, whiter than the white stones it blows across, is the stroke among its pivots.',
'Coil': 'The snail carries its coil on its back and climbs Fuji at the pace the wide rake imposes.',
'Long rake': 'One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is.',
'Turning stone': 'A spring run dry, willow leaves down, stones here and there: a dry landscape, the garden\'s own genre, answering Saigyō\'s willow and clear water (Stepping stone).',
'Bolt': 'The thief takes everything but the moon in the window: what a bolt cannot keep in or out.',
'Stargazer': 'The moon-boat rows into the forest of stars and is hidden: the ripple stone is its one boat on a field of sand.',
'Moonlit sea': 'The sea darkens while the ducks\' voices turn faintly white: a night shore heard rather than seen; the band is its edge.',
'Owl\'s hour': 'Sleepless before dawn, everything behind rises as images, and the moon is asked to light the road ahead.',
'Silver sand': 'The last garden of the main path: the moon clear on the sand a pilgrim monk carried, the one classical poem about temple sand hauled and tended by monks.',
}
for g, why in POS.items():
D['pairings'][g] = why
for old in removed:
if old not in D['gardens'].values() and old != D['practice']:
del P[old]
assert 'teika-21' in P and D['gardens']['Owl\'s hour'] == 'teika-21'
used = list(D['gardens'].values()) + [D['practice']]
assert len(used) == len(set(used)) == len(P) == 149, (len(used), len(set(used)), len(P))
for pid, p in P.items():
assert p['translation'] and p['japanese'] and p['reverified'].startswith('yes') and p.get('author_dates'), pid
D['about']['revisions'] = [
{'date': TODAY, 'pass': 2, 'garden': g, 'from': old_id, 'from_text': removed[old_id]['japanese'], 'to': pid, 'why': why}
for (g, pid, why), old_id in zip(SWAPS, [r for r in removed])
]
# (the zip above pairs swaps with removed ids in order; check it)
for rec, (g, pid, why) in zip(D['about']['revisions'], SWAPS):
assert rec['garden'] == g and rec['to'] == pid
D['about']['selection'] += (f' Second pass, {TODAY}: 18 pairings changed for better classical poems (16 poems new to the '
'selection, 1 from Astra\'s unused candidates, 1 moved); see revisions.')
D['about']['translations'] = ('English versions are working translations, not quotations of published ones: GPT-6 Astra\'s, '
'reviewed by Claude, with ' + str(sum('revised' in p['translation_credit'] for p in P.values())) +
' revised and ' + str(sum('New translation' in p['translation_credit'] for p in P.values())) +
' new (translation_credit).')
json.dump(D, open(PATH, 'w'), ensure_ascii=False, indent=1)
import collections
pc = collections.Counter(P[i]['poet'] for i in used)
print(len(pc), pc.most_common())
print('revised', sum('revised' in p['translation_credit'] for p in P.values()), 'new', sum('New translation' in p['translation_credit'] for p in P.values()))
poems/pass3_astra_review.py — Third pass (2026-10-04): GPT-6 Astra reviewed the selection and proposed 18 changes (10 classical substitutions,
#!/usr/bin/env python3
"""Third pass (2026-10-04): GPT-6 Astra reviewed the selection and proposed 18 changes (10 classical substitutions,
2 originals, 6 translation repairs). Taken: 7 substitutions (one redirected to another garden) and 4 translation repairs;
Shikishi's bamboo was already in. Left out: the two originals (every word the player reads stays a real poem), a second
Rengetsu for Calm (Tomonori's stays), the kite for Wide blade (it already introduces the grooves), and two repairs of
poems the second pass had replaced. Applied once; logged in about.revisions."""
import json
PATH = '/home/claude/zen/poems/samon-poems.json'
ASTRA = '/home/claude/zen/poems/astra-samon-poem-selection.json'
TODAY = '2026-10-04'
D = json.load(open(PATH))
A = json.load(open(ASTRA))
P = D['poems']
ASTRA_TR = f'Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, {TODAY}'
CHECKED = f'yes, {TODAY}: Japanese text and attribution checked against source_url'
NEW = {
'new-shiki-bridge-fish': dict(id='new-shiki-bridge-fish', poet='Masaoka Shiki', author_japanese='正岡子規', author_dates='1867–1902',
composed=1895, form='haiku', japanese='橋蹈めば魚沈みけり春の水', translation="I step on the bridge—\nthe fish sink.\nSpring water.",
translation_credit=f'Working translation by GPT-6 Astra (2026, review pass), revised by Claude, {TODAY}',
collection='獺祭書屋俳句帖抄 (Dassai shooku haiku chō shō), spring 1895', poem_number=None,
source_url='https://mukei-r.net/poem-masaoka/00-haiku-dassai1.htm', source_label='獺祭書屋俳句帖抄 (transcription), 明治28年 春',
display_source='1895', notes='The source prints 沈みけり (Astra\'s draft had the fish sink "deeper"). Composed 1895; the '
'poet died in 1902, so this is the one poem whose author outlived 1900.', reverified=CHECKED),
'new-fushimi-lotus-dew': dict(id='new-fushimi-lotus-dew', poet='Emperor Fushimi', author_japanese='伏見院', author_dates='1265–1317', form='waka',
japanese='こぼれ落つる池のはちすの白露は浮葉の玉とまたなりにけり',
translation="Dew spills\nfrom the pond\'s lotus leaves;\non the floating leaves\nit becomes\na jewel again.",
translation_credit=f'Working translation by GPT-6 Astra (2026, review pass), revised by Claude, {TODAY}',
collection='Gyokuyō Wakashū (headnote: 百首御歌の中に、蓮を)', poem_number=None,
source_url='https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html', source_label='和歌歳時記: 蓮葉 (Yamato Uta)',
display_source='Gyokuyōshū', notes='Lotus, not water lily; the number in the Gyokuyōshū is not given by the source.', reverified=CHECKED),
'new-tamemasa-carp-lotus': dict(id='new-tamemasa-carp-lotus', poet='Reizei Tamemasa', author_japanese='冷泉為尹', author_dates='1361–1417', form='waka',
japanese='池水に藻臥しの鮒や乱るらん蓮のうき葉のゆるぎ立ちぬる',
translation="Hidden in the pondweed,\nare the carp stirring?\nThe lotus leaves\nfloating on the water\nhave begun to tremble.",
translation_credit=ASTRA_TR, collection='Tamemasa senshu (為尹千首), topic: pond lotus', poem_number=None,
source_url='https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html', source_label='和歌歳時記: 蓮葉 (Yamato Uta)',
display_source='Tamemasa senshu', notes='鮒 is the crucian carp. Astra credited the poem to Reizei Tamehide; the source gives 為尹 '
'(Tamemasa, Tamehide\'s grandson).', reverified=CHECKED),
'new-buson-dancers': dict(id='new-buson-dancers', poet='Yosa Buson', author_japanese='与謝蕪村', author_dates='1716–1784', form='haiku',
japanese='四五人に月落かかるおどりかな', translation="Four or five dancers—\nthe moon\nfalls toward them.", translation_credit=ASTRA_TR,
collection='Inscribed on his painting 踊図 (Dancers; Agency for Cultural Affairs)', poem_number=None,
source_url='https://www.kyuhaku.jp/exhibition/exhibition_s50.html', source_label='Kyushu National Museum: 王羲之と日本の書 (exhibit list)',
display_source='on his painting 踊図', notes='The Bon dance; 月落ちかかる is the moon sinking low over the dancers.', reverified=CHECKED),
'new-basho-moon-clouds': dict(id='new-basho-moon-clouds', poet='Matsuo Bashō', author_japanese='松尾芭蕉', author_dates='1644–1694', form='haiku',
japanese='雲折々人をやすむる月見かな', translation="Watching the moon—\nevery so often a cloud\nlets us rest.", translation_credit=ASTRA_TR,
collection='春の日 (Haru no hi), 1686', poem_number=None,
source_url='https://www2.yamanashi-ken.ac.jp/~itoyo/basho/shitibusyu/harunohi.htm',
source_label='Yamanashi Prefectural University, Bashō database: 俳諧七部集, 春の日', display_source='Haru no hi (1686)',
notes='', reverified=CHECKED),
}
# Saigyō's snipe, from Astra's unused candidates (its page was checked today: 新古362, headnote 秋、ものへまかりける道にて)
sg = dict(A['poems']['waka-other-05'])
sg.update(author_japanese='西行', author_dates='1118–1190', collection='Shin Kokin Wakashū', poem_number=362, display_source='Shinkokinshū 362',
translation="Even one like me,\nwho has set his heart aside,\nfeels it after all—\na snipe lifts from the marsh\nat autumn dusk.",
translation_credit=ASTRA_TR, reverified=CHECKED)
NEW['waka-other-05'] = sg
# Bashō's grass hut, back from the first pass (checked then against basho-yamadera.com)
gh = {
"id": "new-basho-grass-hut",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "草の戸も住替る代ぞひなの家",
"translation": "Even the grass hut\npasses to new dwellers—\na house of dolls.",
"collection": "Oku no hosomichi (opening hokku)",
"poem_number": null,
"source_url": "https://basho-yamadera.com/en/yamadera/kusanotomo",
"imagery": [
"grass hut",
"new occupants",
"dolls",
"change"
],
"notes": "",
"verification": "",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"translation_credit": "New translation by Claude, 2026-10-04",
"display_source": "Oku no hosomichi"
}
gh['translation'] = "Even this grass hut\nchanges hands—\na house of dolls."
gh['translation_credit'] = f'Working translation by GPT-6 Astra (2026, review pass), revised by Claude, {TODAY}'
NEW['new-basho-grass-hut'] = gh
SWAPS = [
('Koi', 'new-shiki-bridge-fish', 'A step on the bridge, and the fish go down: the walk in changes the pond under it. Astra\'s choice; the whitebait only glittered.'),
('Water lily', 'new-fushimi-lotus-dew', 'Dew spills from the lotus leaf and gathers into a jewel again on the leaf below: lost, then re-formed, as the rings around the stones are. Astra\'s choice over Buson\'s two pond lilies, which only named the plants.'),
('Carp', 'new-tamemasa-carp-lotus', 'The carp are never seen; the trembling lotus leaves prove them, as the ring proves the stone. Astra found the one classical carp poem we lacked; it replaces the octopus traps, a great poem forced onto a fish garden.'),
('Two doors', 'new-basho-grass-hut', 'One hut, two households through its door: the hermit leaves, a family with its dolls arrives. Astra\'s choice over the inn-sign verse "come in this way".'),
('Threshold', 'waka-other-05', 'Renunciation that cannot keep out the snipe lifting from the marsh: a boundary crossed by feeling. Astra\'s choice over Jien\'s announcement that the true path leaves nothing to long for. With Jakuren\'s Dusk and Teika\'s Plain speaking, all three of the Three Evenings are now in the game.'),
('Lantern festival', 'new-buson-dancers', 'Four or five Bon dancers with the moon sinking onto them: the ring of the dance for the garden\'s ring, and the sky brought close. Astra\'s choice over Rengetsu\'s farewell fires, whose "how it moves me" deflated the scene.'),
('Moonrise', 'new-basho-moon-clouds', 'Moon-viewing with its rests: the clouds that interrupt the looking are kind. Astra proposed it for Stargazer, which keeps the Man\'yōshū moon-boat; it replaces Shōtetsu\'s moon over the petal storm here.'),
]
REPAIRS = { # Astra's better translations of poems that stay
'teika-22': "I watch the lamp\nas the window grows light.\nNo one asks\neven whether\nI am still here.",
'waka-other-26': "If I pick one,\nit must be by guess—\nwhite chrysanthemums\nlost in\nthe first frost.",
'SHOT-021': "Even the clouds\nfalter through the dusk—\na cliffside walkway\nunder snow,\nnot a footprint.",
'waka-other-04': "My heart grows clear,\nclearer still,\nendlessly in the moonlight.\nWhat will it become\nat last?",
}
removed = []
for garden, pid, why in SWAPS:
old = D['gardens'][garden]
removed.append((old, P[old]['japanese']))
D['gardens'][garden] = pid
P[pid] = NEW[pid]
D['pairings'][garden] = why
for pid, t in REPAIRS.items():
P[pid]['translation'] = t
P[pid]['translation_credit'] = f'Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, {TODAY}'
used = list(D['gardens'].values()) + [D['practice']]
for old, _ in removed:
if old not in used:
del P[old]
assert len(used) == len(set(used)) == len(P) == 149, (len(used), len(set(used)), len(P))
for pid, p in P.items():
assert p['translation'] and p['japanese'] and p['reverified'].startswith('yes') and p.get('author_dates'), pid
D['about']['revisions'] += [
{'date': TODAY, 'pass': 3, 'garden': g, 'from': old, 'from_text': text, 'to': pid, 'why': why}
for (g, pid, why), (old, text) in zip(SWAPS, removed)]
D['about']['revisions'] += [
{'date': TODAY, 'pass': 3, 'garden': g, 'translation_repair': pid, 'why': 'Astra\'s revised translation; the poem stays.'}
for pid, g in (('teika-22', 'Lantern'), ('waka-other-26', 'White stones'), ('SHOT-021', 'Hairpin'), ('waka-other-04', 'Spiral'))]
D['about']['selection'] += (f' Third pass, {TODAY}: GPT-6 Astra reviewed the selection; of its 18 proposals, 7 substitutions '
'(one moved to another garden) and 4 translation repairs were taken; its two original poems were not '
'(every word the player reads is a real poem).')
D['about']['translations'] = ('English versions are working translations, not quotations of published ones: GPT-6 Astra\'s, '
'reviewed by Claude, with ' + str(sum('revised' in p['translation_credit'] for p in P.values())) +
' revised and ' + str(sum('New translation' in p['translation_credit'] for p in P.values())) +
' new (translation_credit).')
D['about']['texts'] = ('Every Japanese text and attribution was re-checked on ' + TODAY + ' against a page that prints it '
'(source_url; reverified records the check). Every poem was written before 1900 (one, Shiki\'s, in 1895, '
'by a poet who died in 1902); the originals are public domain.')
json.dump(D, open(PATH, 'w'), ensure_ascii=False, indent=1)
import collections
pc = collections.Counter(P[i]['poet'] for i in used)
print(len(pc), pc.most_common())
print('revised', sum('revised' in p['translation_credit'] for p in P.values()), 'new', sum('New translation' in p['translation_credit'] for p in P.values()))
anames = {l['name']: l['poem_id'] for l in A['levels']}
print('new to selection', sum(i not in A['poems'] for i in used), 'changed since Astra', sum(g in anames and anames[g] != D['gardens'][g] for g in D['gardens']), 'revisions', len(D['about']['revisions']))
poems/samon-poems.json
{
"about": {
"title": "Samon: poems for 148 gardens and the practice garden",
"selection": "Poems chosen and paired with the 136 gardens of the earlier campaign by GPT-6 Astra (2026; its file is astra-samon-poem-selection.json, with 41 unused candidates). Carried over to the 148-garden campaign, where garden names follow layouts, and curated by Claude, 2026-10-04: 13 new gardens paired (3 poems new to the selection, all by Bashō), 3 pairings changed (2 carp poems attributed to Shiki whose texts could not be confirmed replaced by verified Bashō poems; Bashō’s old pond moved to the first ripple stone). Second pass, 2026-10-04: 18 pairings changed for better classical poems (16 poems new to the selection, 1 from Astra's unused candidates, 1 moved); see revisions. Third pass, 2026-10-04: GPT-6 Astra reviewed the selection; of its 18 proposals, 7 substitutions (one moved to another garden) and 4 translation repairs were taken; its two original poems were not (every word the player reads is a real poem).",
"texts": "Every Japanese text and attribution was re-checked on 2026-10-04 against a page that prints it (source_url; reverified records the check). Every poem was written before 1900 (one, Shiki's, in 1895, by a poet who died in 1902); the originals are public domain.",
"translations": "English versions are working translations, not quotations of published ones: GPT-6 Astra's, reviewed by Claude, with 25 revised and 17 new (translation_credit).",
"fields": "gardens: garden name → poem id; practice: poem id; pairings: garden name → why that poem; poems: the texts, sources and notes; display_source: the short source shown on the poem card.",
"revisions": [
{
"date": "2026-10-04",
"pass": 2,
"garden": "The corridor",
"from": "R420",
"from_text": "ふみわけて ゆけど夢ぢは あともなし 思ひねにみし 花のしら雪",
"to": "new-basho-sweep-garden",
"why": "The first poem of the game is the chore itself: a guest sweeps the temple garden before going on. Rengetsu's dream-path (footprints that vanish) was good, but this is the premise."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Bamboo",
"from": "R115",
"from_text": "日にそひて めでたきふしや かぞふらん 千代をこめたる 宿の若竹",
"to": "waka-other-11",
"why": "Rengetsu's young bamboo was a felicitation (count its lucky joints, a thousand years): polite and empty. Shikishi's bamboo rustles at a window and cuts a doze short."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Calm",
"from": "R670",
"from_text": "しづかなる ねざめのとこに 音づれて 心のちりを はらふやま風",
"to": "new-tomonori-hi33",
"why": "The dust-of-the-heart swept by mountain wind is a cliché. Tomonori's tranquil spring light names the garden's score, a calm heart (しづ心), by asking why the petals lack it."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Plain stone",
"from": "new-basho-grass-hut",
"from_text": "草の戸も住替る代ぞひなの家",
"to": "new-ryokan-new-pond",
"why": "The grass hut was a forced pairing. Ryōkan's new pond, where the frog makes no sound, is the garden before the ripple stone; Bashō's old pond follows with the stone and its ring."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Tatami",
"from": "R121",
"from_text": "しくもをし まくもをしきを 秋草の 花のむしろは 野べながら見ん",
"to": "new-basho-summer-room",
"why": "Rengetsu's mat of flowers is a conceit. Bashō's mountain and garden move into the summer room: the garden becomes a floor to sit on."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "The master's grooves",
"from": "SHOT-009",
"from_text": "学びえよこひねがはしきみな月の風のすがたを大和ことのは",
"to": "new-basho-hida",
"why": "Shōtetsu's \"learn the shape of the sixth-month wind\" is didactic and obscure in English. Bashō's coolness drawn to a master carpenter's plan is the abbot's grooves exactly: a drawing laid down by a better hand."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "The finished ring",
"from": "R133",
"from_text": "松風は 秋をしらべて 野宮の しめのうちなる すずむしのこゑ",
"to": "new-basho-hikarido",
"why": "Rengetsu's sacred rope was serviceable. The Hall of Light that the summer rains left untouched is the one thing spared when everything around it was washed over, which is what the finished ring asks of the rake."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "His pebbles",
"from": "waka-other-16",
"from_text": "うす霧のはるる朝けの庭みれば草にあまれる秋の白露",
"to": "new-buson-dew-thorns",
"why": "Eifukumon'in's dew overflowing the grass is diffuse. Buson's one drop on each thorn is a mark per point: the pebbles."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Closed circle",
"from": "R405",
"from_text": "おりたちて わたらばたえん 山がはに かぜのかけたる 花の浮橋",
"to": "new-buson-fuji-leaves",
"why": "Rengetsu's bridge of blossoms, not to be stepped on, was apt but minor. Young leaves that bury everything but Fuji leave one shape standing in a covered field, as the raking leaves the ring."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Pivot",
"from": "SHOT-007",
"from_text": "草も木もぬれて色こき山なれや見しより近き夕立のあと",
"to": "new-basho-ishiyama",
"why": "Shōtetsu's mountain that looks nearer after rain had no hold on the garden. The wide rake can turn only against stones; Bashō's autumn wind, whiter than the white stones it blows across, is the stroke among its pivots."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Coil",
"from": "SHOT-011",
"from_text": "露霜にあへずかれ行く秋草の糸よりよわき虫のこゑかな",
"to": "new-issa-snail",
"why": "Shōtetsu's insect voices thinner than thread were there for atmosphere. The snail carries the coil on its back and climbs Fuji at the pace the wide rake imposes."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Long rake",
"from": "SHOT-003",
"from_text": "夕まぐれ野がひの牛は歩みきて霞める道に逢ふ人もなし",
"to": "new-basho-one-field",
"why": "Shōtetsu's ox at dusk was filler. One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Turning stone",
"from": "SHOT-020",
"from_text": "風まぜにあらく落ちしはしづまりてこまかにつもる庭の白雪",
"to": "new-buson-dry-spring",
"why": "Shōtetsu's settling snow was fine but minor. Buson's dried-up spring with stones here and there is a dry landscape, the garden's own genre, and answers Saigyō's willow and clear water earlier in the campaign."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Bolt",
"from": "teika-10",
"from_text": "いづくにかこよひはやどをかり衣ひもゆふぐれのみねのあらしに",
"to": "new-ryokan-thief-moon",
"why": "Teika's tied hunting robe was fastened to the title by a pun. The thief who takes everything but the moon in the window is the poem about what a bolt cannot keep in or out."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Stargazer",
"from": "SHOT-002",
"from_text": "天の原春のみどりの色にすむ月の光はかすむともなし",
"to": "new-manyo-1068",
"why": "Shōtetsu's moon in a green spring sky has no stars. The Man'yōshū moon-boat rowing into the forest of stars is the oldest and best star poem in the language, and the ripple stone is its one boat."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Moonlit sea",
"from": "SHOT-014",
"from_text": "にほの海の霧ふきたつる程ばかり月に見えたる秋のしほかぜ",
"to": "new-basho-sea-darkens",
"why": "Shōtetsu's wind seen in lifted mist was abstract. Bashō's sea darkening while the ducks' voices turn faintly white is a night shore heard rather than seen."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Owl's hour",
"from": "SHOT-017",
"from_text": "まどろまでさ夜もはるかに竹のはの霜にさえたる風の音かな",
"to": "teika-21",
"why": "Teika's looking back over everything and asking the moon to light the road ahead moves here from the capstone: a sleepless hour before dawn is where that poem happens."
},
{
"date": "2026-10-04",
"pass": 2,
"garden": "Silver sand",
"from": "teika-21",
"from_text": "こしかたはみな面影にうかびきぬ行末てらせ秋の夜の月",
"to": "new-basho-moon-sand",
"why": "The capstone of the main path now gets the one classical poem about raked temple sand: the moon clear on the sand a pilgrim monk carried. Teika's farewell poem, good as it was here, goes to Owl's hour."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Koi",
"from": "day-basho-dawn-whitebait",
"from_text": "明ぼのやしら魚しろきこと一寸",
"to": "new-shiki-bridge-fish",
"why": "A step on the bridge, and the fish go down: the walk in changes the pond under it. Astra's choice; the whitebait only glittered."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Water lily",
"from": "supp-buson-pond-lilies-rain",
"from_text": "河骨の二もとさくや雨の中",
"to": "new-fushimi-lotus-dew",
"why": "Dew spills from the lotus leaf and gathers into a jewel again on the leaf below: lost, then re-formed, as the rings around the stones are. Astra's choice over Buson's two pond lilies, which only named the plants."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Carp",
"from": "new-basho-octopus-pots",
"from_text": "蛸壺やはかなき夢を夏の月",
"to": "new-tamemasa-carp-lotus",
"why": "The carp are never seen; the trembling lotus leaves prove them, as the ring proves the stone. Astra found the one classical carp poem we lacked; it replaces the octopus traps, a great poem forced onto a fish garden."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Two doors",
"from": "hn07",
"from_text": "月ぞしるべこなたへ入せ旅の宿",
"to": "new-basho-grass-hut",
"why": "One hut, two households through its door: the hermit leaves, a family with its dolls arrives. Astra's choice over the inn-sign verse \"come in this way\"."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Threshold",
"from": "waka-other-24",
"from_text": "さとりゆくまことの道に入りぬれば恋しかるべき故郷もなし",
"to": "waka-other-05",
"why": "Renunciation that cannot keep out the snipe lifting from the marsh: a boundary crossed by feeling. Astra's choice over Jien's announcement that the true path leaves nothing to long for. With Jakuren's Dusk and Teika's Plain speaking, all three of the Three Evenings are now in the game."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Lantern festival",
"from": "R122",
"from_text": "おくり火の ほかげしらみて かも川の ぼにの月夜ぞ あはれ也ける",
"to": "new-buson-dancers",
"why": "Four or five Bon dancers with the moon sinking onto them: the ring of the dance for the garden's ring, and the sky brought close. Astra's choice over Rengetsu's farewell fires, whose \"how it moves me\" deflated the scene."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Moonrise",
"from": "SHOT-006",
"from_text": "桜花散りかひかくす高嶺より嵐を越えて出づる月かげ",
"to": "new-basho-moon-clouds",
"why": "Moon-viewing with its rests: the clouds that interrupt the looking are kind. Astra proposed it for Stargazer, which keeps the Man'yōshū moon-boat; it replaces Shōtetsu's moon over the petal storm here."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Lantern",
"translation_repair": "teika-22",
"why": "Astra's revised translation; the poem stays."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "White stones",
"translation_repair": "waka-other-26",
"why": "Astra's revised translation; the poem stays."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Hairpin",
"translation_repair": "SHOT-021",
"why": "Astra's revised translation; the poem stays."
},
{
"date": "2026-10-04",
"pass": 3,
"garden": "Spiral",
"translation_repair": "waka-other-04",
"why": "Astra's revised translation; the poem stays."
}
]
},
"gardens": {
"The corridor": "new-basho-sweep-garden",
"Nine squares": "day-issa-frog-mountain",
"Morning dew": "day-basho-melon-dew",
"The pocket": "hn24",
"Two tones": "R126",
"Bamboo": "waka-other-11",
"Sunrise": "day-basho-plum-sunrise",
"First stroke": "hn30",
"Calm": "new-tomonori-hi33",
"Mist": "day-basho-nameless-mountain",
"First bell": "R050",
"Sparrow": "day-issa-orphan-sparrow",
"Cold sand": "teika-14",
"Lantern": "teika-22",
"The long wall": "day-buson-rain-inhabited-house",
"Wind chime": "SHOT-004",
"Pine shadow": "teika-19",
"Heron's step": "teika-20",
"Quiet": "SHOT-005",
"Plain stone": "new-ryokan-new-pond",
"First ripple": "day-basho-old-pond",
"Koi": "new-shiki-bridge-fish",
"Water lily": "new-fushimi-lotus-dew",
"Ripples": "day-basho-ripples-keeping-time",
"Spring": "waka-other-01",
"Twin rings": "day-buson-two-houses",
"Two moons": "teika-11",
"Frog": "day-issa-proud-frog",
"Paired wells": "R102",
"Raindrop": "day-basho-rain-wasp-nest",
"Bell's echo": "day-buson-bell-voice",
"Turtle": "day-issa-frog-on-turtle",
"Stepping stone": "waka-other-03",
"Pond edge": "R111",
"Drip": "waka-other-10",
"Carp": "new-tamemasa-carp-lotus",
"Lotus": "supp-buson-lotus-fragrance",
"Still water": "day-basho-cuckoo-over-water",
"Well": "day-chiyoni-morning-glory-water",
"Calm water": "new-basho-kiyotaki-moon",
"First wave": "day-basho-green-needles-water",
"High tide": "hn04",
"Sandbar": "hn17",
"Waves": "teika-15",
"Swell": "day-buson-spring-sea",
"Ichimatsu": "hyakunin-17",
"Tatami": "new-basho-summer-room",
"Noon sun": "day-basho-sunlit-leaves",
"Estuary": "day-basho-swift-mogami",
"Island in the current": "supp-basho-hot-day-sea",
"Breakers": "supp-basho-evening-wave-flowers",
"Current": "R034",
"Shallows": "day-buson-river-crossing",
"Reef": "waka-other-28",
"Low tide": "day-basho-low-tide-willow",
"Crosscurrent": "SHOT-008",
"Undertow": "waka-other-18",
"Weave": "R112",
"Lattice": "hyakunin-14",
"Open ground": "waka-other-07",
"First grooves": "day-buson-kite-yesterday",
"His first lines": "teika-06",
"Interrupted": "teika-01",
"The master's grooves": "new-basho-hida",
"Halfway": "waka-other-02",
"The finished ring": "new-basho-hikarido",
"The abbot's ring": "hn03",
"His grooves": "waka-other-06",
"His pebbles": "new-buson-dew-thorns",
"Pale and dark": "hn11",
"The abbot's pebbles": "SHOT-010",
"Ripple and groove": "waka-other-12",
"Pebbles in the current": "waka-other-29",
"Left for you": "waka-other-30",
"Plain speaking": "teika-07",
"Unfinished": "SHOT-025",
"Called away": "teika-12",
"Old grooves": "waka-other-25",
"Continuation": "SHOT-015",
"Closed circle": "new-buson-fuji-leaves",
"White stones": "waka-other-26",
"Black stones": "hn01",
"Go board": "SHOT-016",
"Counting stones": "waka-other-08",
"Narrow rake": "hn22",
"Wide blade": "day-basho-bamboo-painting",
"Pivot": "new-basho-ishiyama",
"Crescent": "teika-02",
"Wide rake": "teika-24",
"Whorl": "waka-other-14",
"Wake": "teika-04",
"Dragonfly": "supp-issa-dragonfly-river",
"Spiral": "waka-other-04",
"Lanternlight": "teika-23",
"Hairpin": "SHOT-021",
"Brace": "hn09",
"Long rake": "new-basho-one-field",
"Turning stone": "new-buson-dry-spring",
"Horseshoe": "teika-08",
"Coil": "new-issa-snail",
"Shell": "day-buson-rain-shells",
"Evening bell": "day-basho-blossom-bells",
"Swallow": "hn26",
"Bat": "R104",
"Owl": "supp-issa-owl-fireflies",
"Ember": "teika-26",
"Firefly": "hn10",
"Long shadow": "waka-other-17",
"Last light": "waka-other-15",
"Cicada": "hn08",
"One gate": "hn29",
"Two gates": "hn12",
"Two doors": "new-basho-grass-hut",
"Gatekeeper": "teika-13",
"Wicket": "hn21",
"Hinge": "hn16",
"Latch": "R124",
"Through the wall": "R131",
"Side gate": "hn15",
"Back gate": "hn13",
"Visitor": "R055",
"Garden path": "hn02",
"Dusk": "waka-other-21",
"Postern": "teika-16",
"Threshold": "waka-other-05",
"Late arrival": "R409",
"Crossing": "teika-09",
"Bolt": "new-ryokan-thief-moon",
"Lantern festival": "new-buson-dancers",
"Moonrise": "new-basho-moon-clouds",
"Dark water": "waka-other-09",
"Stargazer": "new-manyo-1068",
"The abbot's garden": "waka-other-13",
"Moonbeam": "teika-03",
"Before dawn": "day-buson-white-plum-dawn",
"Silver sand": "new-basho-moon-sand",
"Starfield": "teika-25",
"Night heron": "SHOT-024",
"Owl's hour": "teika-21",
"Milky Way": "hn05",
"Frost": "teika-17",
"Moonlit sea": "new-basho-sea-darkens",
"Midnight": "SHOT-013",
"Deep night": "waka-other-23",
"Night rain": "R038",
"Moon pool": "hn19",
"Last bell": "SHOT-018",
"Small hours": "SHOT-023"
},
"practice": "R040",
"practice_poem_reverified": true,
"pairings": {
"The corridor": "The first poem of the game is the chore itself: a guest sweeps the temple garden before going on, as the player rakes each garden and moves on.",
"Nine squares": "The little frog's unhurried attention suits a small garden that first rewards looking before moving.",
"Morning dew": "Cool dirt under a hand is a tactile match for touching and marking a fresh surface.",
"The pocket": "The pocket becomes a refuge: a small protected place within a larger field.",
"Two tones": "Sunlit height and lingering darkness give the alternating tones a natural counterpart.",
"Bamboo": "Bamboo leaves worried by wind at a window, cutting a doze short.",
"Sunrise": "The sudden sunrise gives a compact garden a moment of opening and discovery.",
"First stroke": "A finger writing in empty space mirrors the player's first deliberate drawn line.",
"Calm": "Tranquil spring light, and petals that fall without a calm heart (しづ心): the quality the garden scores, named by its absence.",
"Mist": "The poem lets a landscape emerge from haze without requiring the scattered stones to depict a particular mountain.",
"First bell": "A bell becoming perceptible through dawn mist supplies the chapter's first clear auditory presence.",
"Sparrow": "Issa's invitation to play gives this early garden an affectionate, welcoming voice.",
"Cold sand": "Cold exposed ground and the lack of shelter match the austerity of the nearly empty square.",
"Lantern": "A solitary lamp persisting into dawn matches the title while preserving the opening world's morning light.",
"The long wall": "Smoke finding passage through a wall offers a gentle counterpoint to the enclosing boundary.",
"Wind chime": "Wind becomes audible and fragrance becomes dark; the title's chime expands into a dawn soundscape.",
"Pine shadow": "Pine wind, color, and flowing water merge just as the dark stone and surrounding lines become one composition.",
"Heron's step": "The passing egret supplies the measured white movement evoked by the title.",
"Quiet": "Quiet is made audible by something almost too small to hear: falling blossoms.",
"Plain stone": "Ryōkan's new pond, where the frog makes no sound: the garden before the ripple stone. Bashō's old pond follows, with the stone and its ring.",
"First ripple": "The ripple stone arrives with the most famous ripple in Japanese poetry: one frog, one sound, rings spreading on still water.",
"Koi": "A step on the bridge, and the fish go down: the walk in changes the pond under it. Astra's choice; the whitebait only glittered.",
"Water lily": "Dew spills from the lotus leaf and gathers into a jewel again on the leaf below: lost, then re-formed, as the rings around the stones are. Astra's choice over Buson's two pond lilies, which only named the plants.",
"Ripples": "Ripples and wind share a rhythm, matching the ring's integration with the surrounding parallel lines.",
"Spring": "Water finding a path through newly opened clefts is a close structural counterpart to solving a route.",
"Twin rings": "Two small dwellings facing one river echo the two separate centers sharing a single garden.",
"Two moons": "The same moonlight held in different sleeves turns the paired circles into a relation across distance.",
"Frog": "A frog sitting stiffly in the grass; Bashō’s old-pond frog moved to First ripple.",
"Paired wells": "A barely seen bridge and the water beneath it suggest the quiet connection between separated circular places.",
"Raindrop": "A tiny trickle negotiating an obstruction suits the first small disturbance around a stone.",
"Bell's echo": "Sound departing its source is an exact counterpart to an echo spreading from a single center.",
"Turtle": "The turtle carries a small living interruption; humor lightens the chapter's circling-water imagery.",
"Stepping stone": "A pause beside water gives the stepping-stone image a bodily sense of rest between movements.",
"Pond edge": "Water descending around rocks and a half-hidden dwelling fit the ring placed at one edge of the bed.",
"Drip": "Intermittent meltwater becomes a string of separate drops at a threshold.",
"Carp": "The carp are never seen; the trembling lotus leaves prove them, as the ring proves the stone. Astra found the one classical carp poem we lacked; it replaces the octopus traps, a great poem forced onto a fish garden.",
"Lotus": "A stem lifting just clear of water concentrates attention on the small center of a large open surface.",
"Still water": "A voice lies across still water like one long unbroken stroke.",
"Well": "The occupied well bucket introduces courtesy toward an existing beauty—a thematic counterpoint to composing the stone’s surrounding ring.",
"Calm water": "A spotless stream under the moon, before the wave band arrives.",
"First wave": "Bashō rewrote his Kiyotaki poem on his deathbed: the clean waves now carry scattered pine needles, as the same garden now carries a wave band. The contrasting pair is a pair of drafts.",
"High tide": "The tide arrives at a gate, joining the water image to the puzzle's fixed entrance.",
"Sandbar": "Water striking a heron’s legs makes the boundary between standing obstacle and moving surface tangible.",
"Waves": "An unseen wind changes the river's visible pattern; the raked bands likewise make motion legible.",
"Swell": "The repeated phrase lets broad, unhurried motion become the poem's form.",
"Ichimatsu": "The river imagined as dyed cloth connects water-patterns with a deliberately arranged textile design.",
"Tatami": "Mountain and garden move into the summer room: the garden becomes a floor to sit on.",
"Noon sun": "Bright light through layered leaves suits the meeting of straight bands and a ring at midday.",
"Estuary": "A river gathering rain into one rapid current matches the narrow marked channel opening into a larger bed.",
"Island in the current": "The river delivers the whole hot day into the sea, giving the small marked current a destination beyond its immediate channel.",
"Breakers": "The wave-crests become flowers, letting repeated parallel marks read as a patterned sea.",
"Current": "The moon flows with the water, making the straight current carry an image as well as a direction.",
"Shallows": "Sandals in the hand bring the shallows down to the scale of bare feet.",
"Reef": "A weir of leaves turns an obstruction into pattern, matching the band and adjacent stone ridge.",
"Low tide": "Retreating water reveals mud and drooping branches, a direct image of an exposed margin.",
"Crosscurrent": "Wind redirects leaves and birds ahead of a shower, answering the level's competing directions.",
"Undertow": "The audible stream threading hidden rock gaps gives the apparent open surface an undercurrent.",
"Weave": "The loom's interrupted sound evokes the patient making behind an interlaced pattern.",
"Lattice": "The deliberately tangled cloth pattern introduces emotional disorder within the garden’s rigidly ordered lattice.",
"Open ground": "Wind crossing open fields at dusk: many rakings fit, before the abbot’s grooves give the ground a single path.",
"First grooves": "A kite flies where yesterday’s sky was: a stroke made now in a trace left earlier, as the rake follows the abbot’s grooves.",
"His first lines": "The vanished wind and its garden of seeming snow connect a maker's passing gesture to the trace left behind.",
"Interrupted": "The broken dream-bridge answers the interrupted groove fragments while preserving the poem's abruptness.",
"The master's grooves": "Coolness drawn to a master carpenter's plan: the abbot's grooves are a drawing laid down by a better hand, which the rake follows.",
"Halfway": "Choosing a new route instead of last year's marked path suits a garden that overturns an obvious opening move.",
"The finished ring": "The Hall of Light that the summer rains left untouched: the one thing spared when everything around it is washed over, which is what the finished ring asks of the rake.",
"The abbot's ring": "Walking around a moonlit pond respects an existing center and turns attention into a circuit.",
"His grooves": "Saigyō wishes for another hut beside his own: the abbot’s work set beside the player’s.",
"His pebbles": "One drop of dew on each thorn: a mark per point, as the abbot's grooves become pebbles.",
"Pale and dark": "Moon and sun occupy opposite sides of one scene, giving the two kinds of pebble a balanced visual counterpart.",
"The abbot's pebbles": "Moonlight turns dew into white jewels; the pebbles likewise ask the player to read small bright marks carefully.",
"Ripple and groove": "A returning scent joins a present dream to an absent past, as new raking continues existing grooves.",
"Pebbles in the current": "A faint stream threading moss and rock matches the single current passing through scattered instructions.",
"Left for you": "The question of what to leave behind belongs naturally beside a few marks entrusted to another person.",
"Plain speaking": "The poem's stripped landscape suits a garden whose sparse marks state exactly what is needed.",
"Unfinished": "A single remnant after a dream suits the few surviving fragments of an unfinished composition.",
"Called away": "This is the deliberately elegiac choice: work remains after an absence. The poem mourns Teika's mother; it does not establish the game's master's fate.",
"Old grooves": "The old scent persists despite uncertainty about people, matching inherited marks whose maker is absent.",
"Continuation": "Generations leave the moon behind for later viewers, as a groove can be taken up by another hand.",
"Closed circle": "Young leaves bury everything but Fuji: one shape left standing in a covered field, as the raking leaves the ring.",
"White stones": "White flowers lost against frost make discrimination within an almost uniform surface the subject.",
"Black stones": "One dark crow on a bare branch matches the economy of a small decisive dark mark.",
"Go board": "The bright marks invite a question and give no spoken answer, fitting a board of silent indications.",
"Counting stones": "Water's white beads become countable in moonlight, an unusually close match to reading many little stones.",
"Narrow rake": "A thin firefly drifting on: the ordinary rake, one last time before the wide one.",
"Wide blade": "Coolness becomes visible through drawn bamboo; the new tool likewise imposes a form that can carry a sensation.",
"Pivot": "The wide rake turns only against stones; Bashō's autumn wind, whiter than the white stones it blows across, is the stroke among its pivots.",
"Crescent": "The partly clouded moon echoes a broken or softened arc; the poem does not specify a crescent phase.",
"Wide rake": "The sweeping wind clears an entire prospect, echoing the wide tool's large-scale revelation of order.",
"Whorl": "Blossoms repeatedly return around a rock: a direct physical counterpart to the whorl's encircling run.",
"Wake": "Returning wings carry the weather with them, offering an aerial counterpart to a wake left by passage.",
"Dragonfly": "The small dragonfly touches a whole river with its tail, matching the disproportion between the single projection and the surrounding surface.",
"Spiral": "Attention grows clearer without reaching a final limit, fitting the spiral's inward movement.",
"Lanternlight": "A lamp turned away from the blossoms to wait for moonrise; it shares its lamp with the Lantern garden.",
"Hairpin": "A precarious, untracked passage matches the problem of finding a difficult passage and then erasing its footprints.",
"Brace": "The bending leaves make the limit of a slender form physically visible, a counterpoint to the rake's turning constraint.",
"Long rake": "One whole field planted, then the planters move on: a long single labor, finished and left, as each garden is.",
"Turning stone": "A spring run dry, willow leaves down, stones here and there: a dry landscape, the garden's own genre, answering Saigyō's willow and clear water (Stepping stone).",
"Horseshoe": "The moonlight laid beneath a waiting figure gives an enclosing curve the intimacy of a resting place.",
"Coil": "The snail carries its coil on its back and climbs Fuji at the pace the wide rake imposes.",
"Shell": "Small shells and the exact amount of rain needed to wet them fit a compact, sheltered composition.",
"Evening bell": "The uncertain source of a bell makes the open garden seem to extend beyond its walls.",
"Swallow": "Swift evening birds meet uncertainty about tomorrow, adding vulnerability to the clean turns.",
"Bat": "The fluttering bats provide the title's motion and a rare affectionate view of a dim nocturnal creature.",
"Owl": "The owl seems to call the fireflies by name, giving the large, spare garden a playful voice in the approaching dark.",
"Ember": "The outward fire and inward longing give the title’s ember image an inward life.",
"Firefly": "The apparent fall becomes flight in the next instant, like a route whose reversal changes the meaning of the motion.",
"Long shadow": "Light dwindling on a wall is a direct partner for the long-shadow image.",
"Last light": "Light disappears without a dramatic sunset, matching a quiet final interval rather than a spectacle.",
"Cicada": "Stone receives sound; the sparse board lets that familiar impossible image carry the whole scene.",
"One gate": "The moon at a single gate, and friends thinning as the heat ebbs: the garden before a second gate is cut.",
"Two gates": "The moon overhead and a walker passing through a town and out the other side: the stroke now enters by one gate and leaves by another.",
"Two doors": "One hut, two households through its door: the hermit leaves, a family with its dolls arrives. Astra's choice over the inn-sign verse \"come in this way\".",
"Gatekeeper": "Snow weighing on a cedar suggests a blocked road to someone awaited, matching the guarded approach.",
"Wicket": "A tiny aperture opens onto the Milky Way, enlarging the significance of the little wicket.",
"Hinge": "One flame transferred to another makes a connection visible without exhausting its source.",
"Latch": "An apparent group of approaching visitors becomes moving grass, leaving expectation at the boundary.",
"Through the wall": "A crack in a wall admits moonlight: the poem's opening is as concrete as the level's new access.",
"Side gate": "A gift left at a gate records a visit through what remains, fitting the quieter alternative entrance.",
"Back gate": "Crossing one's own gate changes the role of householder into traveler, making a familiar boundary newly significant.",
"Visitor": "A refused lodging becomes an unexpected welcome beneath blossoms; the visitor's destination changes meaning.",
"Garden path": "An empty road at dusk gives the indirect garden path its solitude.",
"Dusk": "Dusk and solitude have no single color, allowing the spare layout to retain its ambiguity.",
"Postern": "Moonlight enters beneath small eaves, a modest passage suited to a side entrance.",
"Threshold": "Renunciation that cannot keep out the snipe lifting from the marsh: a boundary crossed by feeling. Astra's choice over Jien's announcement that the true path leaves nothing to long for. With Jakuren's Dusk and Teika's Plain speaking, all three of the Three Evenings are now in the game.",
"Late arrival": "The usually late speaker arrives first for blossoms, a light reversal of the title's expectation.",
"Crossing": "Wind, sleeves, and a mountain walkway give the passage between two ends bodily exposure and distance.",
"Bolt": "The thief takes everything but the moon in the window: what a bolt cannot keep in or out.",
"Lantern festival": "Four or five Bon dancers with the moon sinking onto them: the ring of the dance for the garden's ring, and the sky brought close. Astra's choice over Rengetsu's farewell fires, whose \"how it moves me\" deflated the scene.",
"Moonrise": "Moon-viewing with its rests: the clouds that interrupt the looking are kind. Astra proposed it for Stargazer, which keeps the Man'yōshū moon-boat; it replaces Shōtetsu's moon over the petal storm here.",
"Dark water": "The moon crosses from frozen water into a sleeve, while the protected surface remains intact.",
"Stargazer": "The moon-boat rows into the forest of stars and is hidden: the ripple stone is its one boat on a field of sand.",
"The abbot's garden": "The grassless white-gravel courtyard is the anthology's closest literal dry-garden setting; an unseen cricket animates it.",
"Moonbeam": "Moonlight entering through the eaves competes with fragrance on one sleeve, concentrating two sensations in a narrow beam.",
"Before dawn": "Only the coming of day remains; the poem lets the enormous last stretch wait rather than hurry.",
"Silver sand": "The last garden of the main path: the moon clear on the sand a pilgrim monk carried, the one classical poem about temple sand hauled and tended by monks.",
"Starfield": "Clear stars and barely perceptible hail give the scattered pattern both distance and minute texture.",
"Night heron": "A heron departs but white flowers preserve its color, matching an action remembered by its visible trace.",
"Owl's hour": "Sleepless before dawn, everything behind rises as images, and the moon is asked to light the road ahead.",
"Milky Way": "The Milky Way stretches over sea and island, matching the immense field around one stone.",
"Frost": "Frost accumulates night after night until the intervals close, making small repeated deposits fill a large space.",
"Moonlit sea": "The sea darkens while the ducks' voices turn faintly white: a night shore heard rather than seen; the band is its edge.",
"Midnight": "The moon becomes inward and then is forgotten; this is the sequence's deepest absorption in a single center.",
"Deep night": "A dream inside a dream gives the repeated layout another depth rather than merely another name.",
"Night rain": "Rain heard during a sleepless night makes a broad dark expanse intimate and personal.",
"Moon pool": "A boat holds a private moon within a vast frosty prospect; the small ring beside the endpoint likewise gives a broad night garden an intimate center.",
"Last bell": "The bell seems to sound inside the moon, giving the final chime both resonance and cold clarity.",
"Small hours": "A sudden stillness makes one distant break audible, stretching the smallest event across the last hours of night."
},
"practice_pairing": "Hands cup and release threads of water: an idle, tactile pleasure without a required destination.",
"poems": {
"R034": {
"id": "R034",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "とけわたる 氷にそひて はるのよの 月もながるる 井堤の玉川",
"translation": "As the ice\nmelts along the stream,\nthe spring-night moon\nflows with it—\nthe Tama River at Ide.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "34",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1",
"source_label": "Rengetsu Foundation n.d., poem 34",
"imagery": [
"flow",
"moon",
"ice",
"reflection"
],
"notes": "Spring moon and thawing ice share a current.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 34",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R038": {
"id": "R038",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "ふかき夜を 思ひねざめの はる雨は 音を聞にも ぬるる袖かな",
"translation": "Deep in the night\nI wake from troubled sleep.\nThe spring rain—\nmerely to hear it\nwets my sleeves.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "38",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1",
"source_label": "Rengetsu Foundation n.d., poem 38",
"imagery": [
"night",
"rain",
"sleeves",
"tears"
],
"notes": "Wet sleeves link the sound of spring rain to the waking speaker’s tears.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 38",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R040": {
"id": "R040",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "つれづれと 春のながめの 手すさびに むすびて流す 軒のいと水",
"translation": "An idle pastime\nthrough the long spring rain:\nI cup my hands\nbeneath the threads from the eaves\nand let them run.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "40",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1",
"source_label": "Rengetsu Foundation n.d., poem 40",
"imagery": [
"hands",
"play",
"water",
"threads"
],
"notes": "Excellent for the unscored practice garden; nagame suggests both lingering contemplation and long rain.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 40",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R050": {
"id": "R050",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "東山 花まつ比の 朝ぼらけ かすみに匂ふ かねの音かな",
"translation": "At Higashiyama,\nwaiting for the blossoms—\ndaybreak;\nthrough the haze\nthe bell’s sound glows.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "50",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1",
"source_label": "Rengetsu Foundation n.d., poem 50",
"imagery": [
"dawn",
"bell",
"mist"
],
"notes": "Niou crosses visual radiance into sound.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 50",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R055": {
"id": "R055",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "やどかさぬ 人のつらさを 情にて おぼろ月よの 花の下ぶし",
"translation": "Refused a bed,\nI take their unkindness\nfor kindness—\nbeneath blossoms I sleep\nin the hazy moonlight.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "55",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=1",
"source_label": "Rengetsu Foundation n.d., poem 55",
"imagery": [
"gate",
"refusal",
"shelter",
"moon"
],
"notes": "A closed door opens another kind of lodging.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 55",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R102": {
"id": "R102",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "夕月夜 ほのかに見ゆる 小板橋 下行水に 水鶏啼なり",
"translation": "The evening moon:\na little plank bridge\nbarely visible;\nunder it, running water\nand a water rail’s cry.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "102",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3",
"source_label": "Rengetsu Foundation n.d., poem 102",
"imagery": [
"bridge",
"moon",
"water",
"crossing"
],
"notes": "Water rail, not heron.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 102",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R104": {
"id": "R104",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "軒近き 柳になびく かはほりの かげなつかしき 薄月夜かな",
"translation": "Beside the eaves\nbats flutter through the willow—\ntheir silhouettes\nhow dear to me\nin this thin moonlight.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "104",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3",
"source_label": "Rengetsu Foundation n.d., poem 104",
"imagery": [
"bats",
"willow",
"moon",
"shadows"
],
"notes": "A direct match to Bat.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 104",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R111": {
"id": "R111",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "岩つたふ しみづすずしき 山かげの 葉がくれいほに 住人やたれ",
"translation": "Cool springwater\nruns down the rocks\nin the mountain shade.\nWho lives in the hut\nhidden by leaves?",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "111",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3",
"source_label": "Rengetsu Foundation n.d., poem 111",
"imagery": [
"rocks",
"water",
"hut",
"visitor"
],
"notes": "The occupant is left unseen.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 111",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R112": {
"id": "R112",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "さとの子が はたおる音も とだへして 昼ねの比の あつき旅哉",
"translation": "Even the village children’s\nweaving has fallen silent.\nThe hour of the midday nap—\non the road,\nhow hot it is.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "112",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=3",
"source_label": "Rengetsu Foundation n.d., poem 112",
"imagery": [
"weaving",
"interruption",
"noon",
"silence"
],
"notes": "A human interruption in a patterned sound.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 112",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R124": {
"id": "R124",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "むれ立て 人くと見しは 秋風に 尾花が袖の なびく也けり",
"translation": "I thought a crowd\nwas coming toward me—\nonly the sleeves\nof pampas grass\nwaving in the autumn wind.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "124",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=4",
"source_label": "Rengetsu Foundation n.d., poem 124",
"imagery": [
"visitor",
"illusion",
"grass",
"wind"
],
"notes": "An apparent arrival turns into vegetation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 124",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R126": {
"id": "R126",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "さし登る 朝日の山も ふもとには 猶夜をのこす 宇治の河霧",
"translation": "Sunlight climbs\nMount Asahi;\nbelow,\nthe river mist at Uji\nstill holds the night.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "126",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=4",
"source_label": "Rengetsu Foundation n.d., poem 126",
"imagery": [
"dawn",
"two tones",
"mist",
"contrast"
],
"notes": "Light and remaining darkness coexist in one landscape.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 126",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R131": {
"id": "R131",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "山ざとの かべのやれ間の きりぎりす 月もここより させよとぞ鳴",
"translation": "In the mountain village,\na cricket in a broken wall\ncries out:\nlet the moonlight\ncome through here too.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ama no Karumo",
"poem_number": "131",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=4",
"source_label": "Rengetsu Foundation n.d., poem 131",
"imagery": [
"wall",
"opening",
"moon",
"cricket"
],
"notes": "Kirigirisu in older poetry commonly denotes a cricket; the source English uses grasshopper.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Ama no karumo 131",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"R409": {
"id": "R409",
"poet": "Ōtagaki Rengetsu",
"form": "waka",
"japanese": "何事も 人におくるる さがの山 花見ばかりは 先ぞきにける",
"translation": "Always behind\neveryone else—\nbut at Mount Saga,\nfor the cherry blossoms,\nI have arrived first.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Rengetsu Foundation database; collected or inscribed poems",
"poem_number": "database 409",
"source_url": "https://rengetsu.org/poetry_db/index.php?pageNo=13",
"source_label": "Rengetsu Foundation n.d., poem 409",
"imagery": [
"arrival",
"late",
"flowers",
"humor"
],
"notes": "A lightly comic counterpoint for Late arrival; Saga also plays against personal disposition.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "大田垣蓮月",
"author_dates": "1791–1875"
},
"SHOT-004": {
"id": "SHOT-004",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "山風の松に木ぶかき音はして花の香くらき明けぼのの雨",
"translation": "Deep in the pines\nthe mountain wind sounds;\nin the dawn rain,\neven the fragrance\nof blossoms is dark.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "1537",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"pines",
"wind",
"dawn",
"rain",
"blossoms",
"fragrance"
],
"notes": "Topic: 曙春雨 (spring rain at dawn). Preserves the synesthesia of 花の香くらき, dark blossom fragrance.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 1537",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-005": {
"id": "SHOT-005",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "まぎるべき風さへ吹かで散りかかる花の音きく窓のうちかな",
"translation": "Not even wind\nto hide the sound—\nat the window,\nI hear the blossoms\nfall.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "1362",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"silence",
"blossoms",
"window",
"falling petals",
"wind"
],
"notes": "Topic: 閑居花 (flowers in quiet retirement). The translation treats まぎる as obscuring/masking the sound. Source notes a second-line variant in Shōtetsu senshu; the recorded Japanese follows its main text.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 1362",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-008": {
"id": "SHOT-008",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "山づたひ夕立うつる風さきに木の葉も鳥もふかれてぞ行く",
"translation": "Over the mountains\nthe shower shifts;\nin the wind before it,\nthe leaves and birds\nare swept away.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "2635",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"rain",
"wind",
"mountains",
"birds",
"leaves",
"movement"
],
"notes": "Topic: 遠夕立 (a distant shower).",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 2635",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-010": {
"id": "SHOT-010",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "秋草の露とこたふる風もなしただしら玉をみがく月かげ",
"translation": "Not even wind\nto answer, “Dew\non autumn grass”—\nonly the moonlight\npolishing white gems.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "3437",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"dew",
"moon",
"grass",
"pearls",
"silence",
"autumn"
],
"notes": "Topic: 露如玉 (dew like jewels). Alludes to the question about white jewels/dew in Ise monogatari6; wind would disperse the jeweled illusion.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 3437",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-013": {
"id": "SHOT-013",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "すみのぼる心にすめる月をみて月をわするる秋のさ夜中",
"translation": "My heart rises clear;\nI watch the moon\nshining within it,\nand forget the moon—\nautumn midnight.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "3990",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"moon",
"mind",
"meditation",
"forgetting",
"midnight",
"autumn"
],
"notes": "Topic: 閑見月 (quiet moon-viewing). すむ evokes both becoming clear and dwelling; this working translation conveys clarity and inward presence without claiming to reproduce the wordplay fully.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 3990",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-015": {
"id": "SHOT-015",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "むかしよりいく世の人かあかずしてながめすてけん故郷の月",
"translation": "How many generations\nsince long ago\ngazed, never satisfied,\nand left you behind—\nmoon of the old home?",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "4172",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"moon",
"old home",
"time",
"generations",
"departure"
],
"notes": "Topic: 古郷月 (moon at the old home); the source reports 寄月懐旧 in Shōtetsu senshu. 故郷 can suggest an old capital or former home; the English leaves the place unspecified.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 4172",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-016": {
"id": "SHOT-016",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "白玉かなにぞととへば萩のうへの影はこたへずふるさとの月",
"translation": "“White jewels—\nor what?” I ask;\nthe light upon the bush clover\ngives no answer:\nmoon of my old home.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "4704",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"dew",
"moon",
"bush clover",
"pearls",
"old home",
"question"
],
"notes": "Topic: 萩露 (dew on bush clover). Another allusion to Ise monogatari6; distinct from3437, though their motifs overlap.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 4704",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-018": {
"id": "SHOT-018",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "月のうちにひびきのぼると思ふまで霜夜の鐘に影ぞさえ行く",
"translation": "Till it seems\nthe sound climbs into the moon,\nthe frosty night’s bell—\nand the moonlight\ngrows colder.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "5357",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"moon",
"bell",
"frost",
"night",
"sound",
"cold"
],
"notes": "Topic: 冬月 (winter moon). 冴える here permits clearer/brighter as well as colder; this version chooses colder to preserve the sensory crossing.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 5357",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-021": {
"id": "SHOT-021",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "わたりかね雲も夕を猶たどる跡なき雪の嶺のかけはし",
"translation": "Even the clouds\nfalter through the dusk—\na cliffside walkway\nunder snow,\nnot a footprint.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "5885",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"bridge",
"snow",
"footprints",
"clouds",
"dusk",
"path"
],
"notes": "Kakehashi is a precarious mountain walkway or ladder across a steep slope. The source also finds an ambiguity between luminous snow and approaching dusk; no suspension bridge is implied.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 5885",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-023": {
"id": "SHOT-023",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "さ夜風はただ一足にしづまりてをち方きけば雪折の声",
"translation": "The night wind\nfalls still all at once.\nFrom far away,\nas I listen,\na bough breaks under snow.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "6053",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"night",
"wind",
"silence",
"snow",
"branch",
"sound"
],
"notes": "Hitoashi ni is rendered all at once rather than supplying an unverified literal footstep by the wind.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 6053",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-024": {
"id": "SHOT-024",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "むら雨のふる江をよそに飛ぶさぎの跡まで白きおもだかの花",
"translation": "From the rain-swept inlet\na heron flies away;\neven the place it leaves\nis white—\narrowhead flowers.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "9064",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"heron",
"rain",
"inlet",
"flowers",
"whiteness",
"trace"
],
"notes": "Topic: 江雨鷺飛 (heron flying over rain at an inlet). おもだか is the aquatic arrowhead plant, Sagittaria; “arrowhead” is botanical, not a weapon.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 9064",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"SHOT-025": {
"id": "SHOT-025",
"poet": "Shōtetsu",
"form": "waka",
"japanese": "あととめてさむるか夢の中空に孤の雲の残るをぞ見る",
"translation": "Does a dream\nleave this trace on waking?\nIn the open sky\nI see\na single cloud remain.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Sōkonshū (草根集)",
"poem_number": "10428",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/0syoutetu_t.html",
"source_label": "Yamato Uta / Sennin Manshu, Shōtetsu: edition-numbered Japanese text",
"additional_source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syoutetu.html",
"imagery": [
"dream",
"cloud",
"sky",
"trace",
"awakening",
"solitude"
],
"notes": "Topic: 孤夢 (solitary dream). Source explicitly glosses 孤 as ひとり. The relation between a lingering dream and the remaining cloud is kept as a question.",
"numbering_note": "Number follows the source’s Notre Dame Seishin Women’s University Classical Text Series Sōkonshū (vols1–4) text; numbering is edition-specific.",
"verification": "Japanese original and attribution retrieved directly from the cited Shōtetsu anthology page; poem number checked against its companion annotated page. No manuscript or facsimile collation performed.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sōkonshū 10428",
"author_japanese": "正徹",
"author_dates": "1381–1459"
},
"day-basho-bamboo-painting": {
"id": "day-basho-bamboo-painting",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "すヾしさを絵にうつしけり嵯峨の竹",
"translation": "Coolness\nmade into a painting:\nthe bamboo of Saga.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "住吉物語 (Sumiyoshi monogatari)",
"source_url": "https://www.basho.jp/hatonokai/kendaikaisetsu/kendai25_07.html",
"source_label": "Bashō Kaigi, seasonal-word commentary",
"imagery": [
"bamboo",
"coolness",
"painting",
"summer",
"Saga"
],
"notes": "Fresh working translation. Source spelling retained; frequently printed 涼しさを絵にうつしけり嵯峨の竹.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-blossom-bells": {
"id": "day-basho-blossom-bells",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "花の雲鐘は上野か浅草か",
"translation": "Clouds of blossoms—\nthat bell: from Ueno\nor Asakusa?",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "続虚栗 (Zoku minashiguri), 1687",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/hanano.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"blossoms",
"bell",
"temples",
"spring",
"distance"
],
"notes": "Fresh working translation. The two place names identify actual temple districts in Edo.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Zoku minashiguri (1687)",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-cuckoo-over-water": {
"id": "day-basho-cuckoo-over-water",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "ほととぎす声や横たふ水の上",
"translation": "A cuckoo—\nits voice lies out\nacross the water.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Letter to Miyazaki Keikō, Genroku 6, fourth month, day 29 (1693)",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/keiko.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"cuckoo",
"water",
"voice",
"horizontal",
"stillness"
],
"notes": "Fresh working translation. This wording is in Bashō's letter. 藤の実 has the variant ほととぎす声横たふや水の上.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "letter of 1693",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-green-needles-water": {
"id": "day-basho-green-needles-water",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "清滝や波に散り込む青松葉",
"translation": "Kiyotaki—\ngreen pine needles\nscattering into the waves.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "去来抄 (Kyorai shō), wording discussed in the linked commentary; 1694 poem",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu2.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"pine",
"needles",
"stream",
"waves",
"green"
],
"notes": "Fresh working translation. The source discusses this revision alongside 清滝や波に塵なき夏の月. Do not treat the two as independent poems in one anthology.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Kyoraishō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-low-tide-willow": {
"id": "day-basho-low-tide-willow",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "青柳の泥にしだるる潮干かな",
"translation": "Green willow branches\nhang down into the mud—\nthe tide is out.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "炭俵 (Sumidawara)",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/aoyagi.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"willow",
"low tide",
"mud",
"green",
"shore"
],
"notes": "Fresh working translation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sumidawara (1694)",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-melon-dew": {
"id": "day-basho-melon-dew",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "朝露や撫でて涼しき瓜の土",
"translation": "Morning dew—\nthe dirt on the melon\ncool beneath my hand.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Letter to Sugiyama Sanpū, Genroku 7, sixth month, day 24 (1694)",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu2.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"morning",
"dew",
"melon",
"soil",
"touch",
"coolness"
],
"notes": "Fresh working translation. 土 refers to dirt adhering to the melon in the source's reading.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "letter of 1694",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-nameless-mountain": {
"id": "day-basho-nameless-mountain",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "春なれや名もなき山の薄霞",
"translation": "Spring has come—\na mountain without a name\nin thin haze.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "野ざらし紀行 (Nozarashi kikō), road to Nara section",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/nozarasi/nozara25.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"spring",
"mountain",
"haze",
"mist"
],
"notes": "Fresh working translation. This source prints 薄霞, thin haze, and records 朝霞, morning haze, as a variant. Do not silently mix their readings.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Nozarashi kikō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-old-pond": {
"id": "day-basho-old-pond",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "古池や蛙飛びこむ水の音",
"translation": "The old pond—\na frog jumps in;\nthe sound of water.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蛙合 (Kawazu awase), 1686",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/huruike.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"pond",
"frog",
"water",
"sound",
"ripples"
],
"notes": "Fresh working translation of the Japanese; no English syllable constraint. The Japanese does not specify the number of frogs.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Kawazu awase (1686)",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-plum-sunrise": {
"id": "day-basho-plum-sunrise",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "梅が香にのつと日の出る山路哉",
"translation": "Into plum fragrance\nthe sun suddenly rises—\na mountain path.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "炭俵 (Sumidawara), opening verse, 1694",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/umegaka.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"sunrise",
"plum",
"fragrance",
"mountain path"
],
"notes": "Fresh working translation. のつと gives the rising sun an abrupt, almost casual appearance.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sumidawara (1694)",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-rain-wasp-nest": {
"id": "day-basho-rain-wasp-nest",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "春雨や蜂の巣つたふ屋根の漏り",
"translation": "Spring rain—\nthe roof’s leak trickles\ndown a wasp nest.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "炭俵 (Sumidawara), 1694",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/harsame3.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"spring rain",
"roof",
"nest",
"drips",
"hut"
],
"notes": "Fresh working translation. 蜂 can cover bees or wasps; an exposed old nest beneath the eaves supports 'wasp nest'.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sumidawara (1694)",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-ripples-keeping-time": {
"id": "day-basho-ripples-keeping-time",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "さざ波や風の薫の相拍子",
"translation": "Small ripples\nkeeping time\nwith the fragrant wind.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "笈日記 (Oi nikki), 1694",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/noryo1.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"ripples",
"wind",
"fragrance",
"lake",
"rhythm"
],
"notes": "Fresh working translation. 相拍子 is a musical term; the poem greeted a Noh performer.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Oi nikki",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-sunlit-leaves": {
"id": "day-basho-sunlit-leaves",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "あらたうと青葉若葉の日の光",
"translation": "How holy—\nsunlight through green leaves,\nthrough new leaves.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Nikkō section, journey of 1689",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno051.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"sunlight",
"leaves",
"green",
"temple",
"early summer"
],
"notes": "Fresh working translation. The Nikkō setting carries religious and political praise; this is not merely a neutral nature sketch.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Oku no hosomichi",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-basho-swift-mogami": {
"id": "day-basho-swift-mogami",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "五月雨をあつめて早し最上川",
"translation": "Gathering\nthe summer rains, so swift—\nthe Mogami River.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Mogami River section, journey of 1689",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno24.htm",
"source_label": "Yamanashi Prefectural University, Bashō database",
"imagery": [
"rain",
"river",
"current",
"summer",
"gathering"
],
"notes": "Fresh working translation. 五月雨 means the early-summer rainy season, not modern-calendar May rain.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Oku no hosomichi",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"day-buson-bell-voice": {
"id": "day-buson-bell-voice",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "涼しさや鐘をはなるるかねの声",
"translation": "Coolness—\nthe bell’s voice\nleaves the bell.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://www.basho.jp/hatonokai/kendaikaisetsu/kendai25_07.html",
"source_label": "Bashō Kaigi, seasonal-word commentary",
"imagery": [
"bell",
"sound",
"coolness",
"summer",
"temple"
],
"notes": "Fresh working translation. The linked poets' society page verifies text and authorship but does not name the original collection.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-kite-yesterday": {
"id": "day-buson-kite-yesterday",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "凧きのふの空のありどころ",
"translation": "A kite—\nthere is the place\nwhere yesterday’s sky was.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū)",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"kite",
"sky",
"spring",
"daylight"
],
"notes": "Fresh working translation. Backup. The museum displays the kite character 凧 as its separated components 几巾; the standard character is restored here.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-rain-inhabited-house": {
"id": "day-buson-rain-inhabited-house",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "春雨や人住みて煙壁を洩る",
"translation": "Spring rain—\nsomeone lives here; smoke\nseeps through the wall.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū)",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"rain",
"house",
"smoke",
"spring"
],
"notes": "Fresh working translation. Backup.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-rain-shells": {
"id": "day-buson-rain-shells",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "春雨や小磯の小貝ぬるるほど",
"translation": "Spring rain—\njust enough to wet small shells\non a little shore.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū)",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"rain",
"shells",
"shore",
"spring"
],
"notes": "Fresh working translation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-river-crossing": {
"id": "day-buson-river-crossing",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "夏河を越すうれしさよ手に草履",
"translation": "Such pleasure, crossing\nthe summer river—\nsandals in my hand.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū)",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"river",
"feet",
"summer",
"crossing"
],
"notes": "Fresh working translation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-spring-sea": {
"id": "day-buson-spring-sea",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "春の海終日のたりのたり哉",
"translation": "The spring sea\nrolls slowly, rolls slowly,\nall day.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū)",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"sea",
"waves",
"spring",
"daylight"
],
"notes": "Fresh working translation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-two-houses": {
"id": "day-buson-two-houses",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "さみだれや大河を前に家二軒",
"translation": "Summer rains—\nfacing the great river,\ntwo houses.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū)",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"rain",
"river",
"houses",
"summer"
],
"notes": "Fresh working translation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-buson-white-plum-dawn": {
"id": "day-buson-white-plum-dawn",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "しら梅に明る夜ばかりとなりにけり",
"translation": "From now on,\nnights will break\non white plum blossoms.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "から檜葉 (Karahiba), headnote 初春",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Literature Museum",
"imagery": [
"plum",
"white",
"dawn",
"spring"
],
"notes": "Fresh working translation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Karahiba",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"day-chiyoni-morning-glory-water": {
"id": "day-chiyoni-morning-glory-water",
"poet": "Fukuda Chiyo-ni",
"form": "haiku",
"japanese": "朝顔につるべとられてもらひ水",
"translation": "Morning glories\nhave taken the well bucket—\nI ask for water elsewhere.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "千代尼句集 (Chiyo-ni kushū), Kihaku, ed., Hōreki 14 (1764)",
"source_url": "https://www.city.hakusan.lg.jp/machi/ryokka/1003077.html",
"source_label": "Hakusan City, Chiyo-ni documentation",
"imagery": [
"morning glory",
"water",
"well",
"vine",
"bucket",
"care"
],
"notes": "Fresh working translation. Uses the に version printed in the 1764 collection. Hakusan's cultural-property record verifies this and discusses the や variant: https://www.city.hakusan.lg.jp/bunka/bunkazai/1006096/1002328/1009253.html .",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Chiyo-ni kushū (1764)",
"author_japanese": "千代尼",
"author_dates": "1703–1775"
},
"day-issa-frog-mountain": {
"id": "day-issa-frog-mountain",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "ゆうぜんとして山を見る蛙哉",
"translation": "Serenely\ngazing at the mountain:\na frog.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "七番日記 (Shichiban nikki), Bunka 10 (1813)",
"source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104001012&entry=4104&group=hirarajp&shid=6a9e69c2",
"source_label": "Issa haiku database, entry 4104",
"imagery": [
"frog",
"mountain",
"stillness",
"spring",
"watching"
],
"notes": "Fresh working translation. Also independently attested by Nagareyama municipal poem-monument documentation: https://m.city.nagareyama.chiba.jp/life/1001505/1001700/1001730/1001735.html .",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shichiban nikki",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"day-issa-frog-on-turtle": {
"id": "day-issa-frog-on-turtle",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "亀どのに負さつて鳴蛙哉",
"translation": "Riding on\nMr. Turtle’s back,\na frog calls.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "七番日記 (Shichiban nikki), Bunka 12 (1815)",
"source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104001012&entry=4116&group=hirarajp&shid=6a9e69c2",
"source_label": "Issa haiku database, entry 4116",
"imagery": [
"frog",
"turtle",
"pond",
"spring",
"voice"
],
"notes": "Fresh working translation. どの humorously grants the turtle an honorific.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shichiban nikki",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"day-issa-orphan-sparrow": {
"id": "day-issa-orphan-sparrow",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "我と来て遊べや親のない雀",
"translation": "Come along with me\nand play, little sparrow\nwith no parents.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://www.issakinenkan.com/around/",
"source_label": "Issa Memorial Museum, Myōsenji poem monument",
"imagery": [
"sparrow",
"play",
"company",
"spring"
],
"notes": "Fresh working translation. Collection not specified by the museum's monument record.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"day-issa-proud-frog": {
"id": "day-issa-proud-frog",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "草陰につんとしている蛙かな",
"translation": "In the grass’s shade,\na frog sits\nwith its nose in the air.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "七番日記 (Shichiban nikki), Bunka 11 (1814)",
"source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104001012&entry=4110&group=hirarajp&shid=6a9e69c2",
"source_label": "Issa haiku database, entry 4110",
"imagery": [
"frog",
"grass",
"shade",
"spring",
"stillness"
],
"notes": "Fresh working translation. 'Nose in the air' renders the aloof or haughty posture of つんとしている rather than adding a literal action.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shichiban nikki",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hn01": {
"id": "hn01",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "枯枝に烏のとまりけり秋の暮",
"translation": "On a bare branch\na crow has settled—\nautumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "芭蕉俳句全集 (1903), 秋・秋の暮",
"source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集",
"source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), autumn section",
"imagery": [
"crow",
"bare branch",
"autumn",
"dusk"
],
"notes": "The cited edition supplies the とまりけり version; other established versions differ. New working translation of this Japanese text. Historically a hokku.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn02": {
"id": "hn02",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "此道や行く人なしに秋の暮",
"translation": "This road—\nno one taking it\nin the autumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "芭蕉俳句全集 (1903), 秋・秋の暮; heading 所思",
"source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集",
"source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), autumn section",
"imagery": [
"empty path",
"autumn",
"dusk",
"solitude"
],
"notes": "Do not count the alternative 人声やこの道かへる秋の暮 as an independent companion poem: it belongs to this poem's revision history.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn03": {
"id": "hn03",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "名月や池をめぐりて夜もすがら",
"translation": "The harvest moon—\naround the pond I walk\nthe whole night through.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "あつめ句 (source's attribution)",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/meigetu1.htm",
"source_label": "Bashō Database: 名月や池をめぐりて夜もすがら",
"imagery": [
"pond",
"circular path",
"moon",
"whole night"
],
"notes": "The subject is implicit in Japanese; the English supplies I. The source dates the poem to Jōkyō 3, eighth month, fifteenth night (1686).",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Atsume ku",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn04": {
"id": "hn04",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "名月や門にさしくる潮がしら",
"translation": "The harvest moon—\nthe leading edge of the tide\nreaches my gate.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/meigetu2.htm",
"source_label": "Bashō Database: 名月や門にさしくる潮がしら",
"imagery": [
"gate",
"rising tide",
"moon",
"water boundary"
],
"notes": "My is implicit rather than stated. Preserve the meeting of water and threshold; do not turn the tide into an ordinary garden stream.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn05": {
"id": "hn05",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "荒海や佐渡によこたふ天河",
"translation": "A rough sea—\nstretched over Sado,\nthe Milky Way.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "おくのほそ道, 越後路",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno28.htm",
"source_label": "Bashō Database: Oku no hosomichi, Echigo road",
"imagery": [
"rough sea",
"island",
"Milky Way",
"night sky"
],
"notes": "Sado is the named island, not a generic rock. The poem's arrangement need not be treated as a literal astronomical observation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Oku no hosomichi",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn08": {
"id": "hn08",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "閑さや岩にしみ入蝉の声",
"translation": "Stillness—\ninto the stone\nthe cicadas’ cry sinks.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "おくのほそ道, 立石寺",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno23.htm",
"source_label": "Bashō Database: Oku no hosomichi, Risshakuji",
"imagery": [
"stone",
"cicadas",
"stillness",
"temple",
"summer"
],
"notes": "Japanese does not specify singular or plural cicadas. This is a summer daytime poem, suitable for the cicada motif but not evidence of nocturnal cicadas.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Oku no hosomichi",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn09": {
"id": "hn09",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "初雪や水仙の葉のたわむまで",
"translation": "The first snowfall—\nenough to bend\nthe narcissus leaves.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "あつめ句 (source's attribution)",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/hatyuki2.htm",
"source_label": "Bashō Database: 初雪や水仙の葉のたわむまで",
"imagery": [
"first snow",
"narcissus",
"bent leaves",
"weight"
],
"notes": "The cited source uses modern たわむ, retained here. Snow, not night, is explicit.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Atsume ku",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn10": {
"id": "hn10",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "草の葉を落ちるよりとぶ螢哉",
"translation": "From a blade of grass\nit drops—and is already flying:\na firefly.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "芭蕉俳句全集 (1903), 夏・螢",
"source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集",
"source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), summer section",
"imagery": [
"firefly",
"grass",
"falling",
"taking flight"
],
"notes": "Working translation reads より as the immediate change from falling to flying. Preserve the tiny movement rather than adding a metaphysical explanation.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"hn11": {
"id": "hn11",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "菜の花や月は東に日は西に",
"translation": "Rapeseed blossoms—\nthe moon in the east,\nthe sun in the west.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集; heading 春景",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "Hyōgo Prefectural Museum of Art, Edo–Hyōgo Haiku Gallery: Buson",
"imagery": [
"flowers",
"east",
"west",
"sunset",
"moonrise",
"opposed directions"
],
"notes": "The Japanese names rapeseed blossoms; the translation does not add a color.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn12": {
"id": "hn12",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "月天心貧しき町を通りけり",
"translation": "The moon at heaven’s center—\nI pass through\na poor town.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集",
"source_url": "https://www.nippon.com/ja/japan-topics/b09645/",
"source_label": "Hyōgo Prefectural Museum of Art, Edo–Hyōgo Haiku Gallery: Buson",
"imagery": [
"high moon",
"night walk",
"town",
"poverty"
],
"notes": "天心 places the moon centrally overhead; it does not name a moon phase.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn13": {
"id": "hn13",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "門を出れば我も行人秋のくれ",
"translation": "Outside my gate\nI too am someone on the road—\nautumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Letter to 了角 and 乙総, An’ei 3 (1774), ninth month, second day",
"source_url": "https://gashuu.hateblo.jp/entry/2024/12/04/211201",
"source_label": "剣持雅舟のブログ: 蕪村の句「門を出れば我も行く人」 (2024)",
"imagery": [
"gate",
"threshold",
"traveler",
"autumn",
"dusk"
],
"notes": "行人 is a passerby or traveler. The change in the speaker's status happens upon crossing the gate.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (a secondary source quoting the letter)",
"display_source": "letter of 1774",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn15": {
"id": "hn15",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "鮎くれてよらで過行夜半の門",
"translation": "He leaves me sweetfish\nand goes on without coming in—\nthe gate at midnight.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村句集; composed 1768",
"source_url": "https://www.nippon.com/ja/japan-topics/b09625/",
"source_label": "Nippon.com, 古典俳諧への招待: 鮎くれてよらで過行夜半の門 (2023)",
"imagery": [
"gate",
"visitor",
"gift",
"sweetfish",
"midnight",
"passing on"
],
"notes": "Gender and recipient are supplied for readable English; Japanese leaves the subject unstated. 夜半 means the middle of the night, not necessarily exactly 00:00.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson kushū",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn16": {
"id": "hn16",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "燭の火を燭にうつすや春の夕",
"translation": "A candle’s flame\npassed to another candle—\nspring evening.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "蕪村の俳句集, spring poems",
"imagery": [
"candle",
"flame",
"transfer",
"evening",
"spring"
],
"notes": "Japanese verified in this anthology and independently in the digitized 季題類別蕪村名句選集 search result. No original collection number established.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn17": {
"id": "hn17",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "夕風や水青鷺の脛を打つ",
"translation": "The evening breeze—\nwater slaps\nthe heron’s shins.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://www.aozora.gr.jp/cards/000305/files/47985_41579.html",
"source_label": "Masaoka Shiki, Haijin Buson, section 客観的美, at Aozora Bunko",
"imagery": [
"evening breeze",
"water",
"heron",
"legs",
"ripples"
],
"notes": "青鷺 is the grey heron; English uses the unqualified heron. The water, not the bird, is the subject of 打つ.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn19": {
"id": "hn19",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "霜百里舟中に我月を領ス",
"translation": "A hundred leagues of frost—\nhere in my boat\nthe moon is mine.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "蕪村自筆句帳",
"source_url": "https://www.nippon.com/ja/japan-topics/b09653/",
"source_label": "Nippon.com, 古典俳諧への招待: 霜百里舟中に我月を領ス (2023)",
"imagery": [
"frost",
"boat",
"moon",
"night",
"wide space"
],
"notes": "百里 literally means a hundred ri; this working translation renders its immense reach idiomatically rather than falsely converting ri into miles. The source dates the composition tentatively to 1775.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Buson jihitsu kuchō",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"hn21": {
"id": "hn21",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "うつくしや障子の穴の天の川",
"translation": "How beautiful—\nthe Milky Way through a hole\nin the paper screen.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "志多良; Bunka 10 (1813)",
"source_url": "https://www.issakinenkan.com/diary/一茶と善光寺⑨「上原文路宅跡」/",
"source_label": "Issa Memorial Museum: 一茶と善光寺⑨「上原文路宅跡」 (2015)",
"imagery": [
"paper screen",
"hole",
"Milky Way",
"night sky",
"framed view"
],
"notes": "The museum ties it to Issa's illness and recovery at Uehara Bunro's house. This working translation adopts the ordinary literal view through the hole, without adding the medical context to the verse.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shidara",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hn22": {
"id": "hn22",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "痩蛍ふはりふはりとながらふる",
"translation": "A thin firefly—\ndrifting, drifting,\nit lives on.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"source_url": "https://www.issakinenkan.com/diary/ゲンジボタルについて学びませんか/",
"source_label": "Issa Memorial Museum: ゲンジボタルについて学びませんか (2014)",
"imagery": [
"firefly",
"frailty",
"floating",
"survival"
],
"notes": "ながらふる means to continue living; the translation keeps that meaning rather than merely saying it floats.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hn24": {
"id": "hn24",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "我家や町の蛍の逃所",
"translation": "My house—\nwhere the town’s fireflies\ncome to escape.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://www.issakinenkan.com/2014/06/",
"source_label": "Issa Memorial Museum: ゲンジボタルの季節ですよ (2014-06-24)",
"imagery": [
"house",
"fireflies",
"refuge",
"town",
"entrance"
],
"notes": "逃所 is a place of escape or refuge. The Japanese does not specify what the fireflies escape from.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hn26": {
"id": "hn26",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "夕燕我には翌のあてはなき",
"translation": "Evening swallows—\nas for tomorrow,\nI have nothing to count on.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "文化句帖; database entry 3470",
"source_url": "https://ohh.sisos.co.jp/cgi-bin/openhh/jsearch.cgi?dbi=20140103235455_20140104000746&group=hirarajp&s_entry=3460&se0=0&sf0=0&sk0=",
"source_label": "Issa Haiku Database, entry 3470",
"imagery": [
"swallows",
"evening",
"journey",
"uncertain lodging",
"tomorrow"
],
"notes": "Entry 3470 has あてはなき; adjacent entries document different versions. Lodging is a plausible contextual interpretation of あて, but the translation does not insert it. The number is a database ID, not a numbering supplied by Issa.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Bunka kuchō",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hn29": {
"id": "hn29",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "門の月暑がへれば友もへる",
"translation": "Moon at the gate—\nas the heat lessens,\nso do the friends.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "八番日記; Bunsei 4 (1821)",
"source_url": "https://www.janis.or.jp/users/kyodoshi/issaku-14.htm",
"source_label": "Issa hokku zenshū (14), 残暑 section",
"imagery": [
"gate",
"moon",
"waning heat",
"fewer visitors",
"late summer"
],
"notes": "The source also records a variant ending 人もへる. The translation follows 友, friends, in the selected text.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Hachiban nikki",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hn30": {
"id": "hn30",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "青空に指で字をかく秋の暮",
"translation": "In the blue sky\nI write with my finger—\nautumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "七番日記; Bunka 11 (1814)",
"source_url": "https://www.janis.or.jp/users/kyodoshi/issaku-14.htm",
"source_label": "Issa hokku zenshū (14), 秋の暮 section",
"imagery": [
"blue sky",
"finger",
"writing",
"drawn trajectory",
"autumn dusk"
],
"notes": "The Japanese supplies no written word or character; the translation does not invent one.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shichiban nikki",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"hyakunin-14": {
"id": "hyakunin-14",
"poet": "Minamoto no Tōru",
"form": "waka",
"japanese": "みちのくの しのぶもぢずり 誰ゆゑに 乱れそめにし われならなくに",
"translation": "Like Michinoku’s Shinobu cloth,\nits pattern tangled—\nfor whose sake\nhas my heart begun to tangle?\nNot by my own doing.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Ogura Hyakunin Isshu",
"poem_number": "14",
"source_url": "https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/14/",
"source_label": "Bunka Net n.d., Hyakunin Isshu 14",
"imagery": [
"cloth",
"pattern",
"disorder",
"love"
],
"notes": "The author is named by his title Kawara no Sadaijin in the source. Shinobu mojizuri is patterned dyed cloth; the image is not a literal woven lattice. The English makes the implied heart explicit.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Hyakunin isshu 14",
"author_japanese": "源融",
"author_dates": "822–895"
},
"hyakunin-17": {
"id": "hyakunin-17",
"poet": "Ariwara no Narihira",
"form": "waka",
"japanese": "ちはやぶる 神代もきかず 竜田川 からくれなゐに 水くくるとは",
"translation": "Not even\nin the age of mighty gods\nwas such a thing heard—\nTatsuta’s waters\ntied and dyed crimson.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ogura Hyakunin Isshu",
"poem_number": "17",
"source_url": "https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/17/",
"source_label": "Bunka Net n.d., Hyakunin Isshu 17",
"imagery": [
"river",
"crimson",
"dyed cloth",
"pattern"
],
"notes": "Uses the tie-dye reading of mizu kukuru; alternative readings of the last phrase exist. The cloth analogy matches the level’s patterned compartments, not its exact checkerboard design.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Hyakunin isshu 17",
"author_japanese": "在原業平",
"author_dates": "825–880"
},
"new-basho-kiyotaki-moon": {
"id": "new-basho-kiyotaki-moon",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "清滝や波に塵なき夏の月",
"translation": "Kiyotaki—\non the waves, not a speck of dust:\nthe summer moon.",
"collection": "1694; revised on his deathbed as 清滝や波に散り込む青松葉",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu2.htm",
"imagery": [
"river",
"waves",
"summer moon",
"clarity"
],
"notes": "",
"verification": "",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"translation_credit": "New translation by Claude, 2026-10-04",
"display_source": ""
},
"supp-basho-evening-wave-flowers": {
"id": "supp-basho-evening-wave-flowers",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "夕晴れや桜に涼む波の華",
"translation": "Evening clears—\nbeneath the cherry tree,\nthe cool bloom of waves.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "真蹟懐紙 (autograph kaishi), Kisakata",
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/haikusyu/kisagata.htm",
"source_label": "Yamanashi Prefectural University Bashō database: 夕晴れや桜に涼む波の華",
"imagery": [
"sea",
"waves",
"cherry tree",
"evening",
"clearing weather"
],
"notes": "New working translation. The source explains 波の華 as the flowerlike glitter of waves, not the winter sea-foam phenomenon also called nami no hana. The headnote describes the evening rain stopping and a boat tour of the inlet.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "autograph sheet",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"supp-basho-hot-day-sea": {
"id": "supp-basho-hot-day-sea",
"poet": "Matsuo Bashō",
"form": "haiku",
"japanese": "暑き日を海に入れたり最上川",
"translation": "The hot day\npoured into the sea—\nMogami River.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Sakata",
"source_url": "https://www.thr.mlit.go.jp/yamagata/river/enc/genre/02-reki/reki0201_002.html",
"source_label": "Japan Ministry of Land, Infrastructure, Transport and Tourism, Yamagata: Mogami River encyclopedia",
"imagery": [
"sea",
"river",
"sun",
"evening",
"summer"
],
"notes": "New working translation. 日 can evoke the hot day and the sun. The official explanation reads the river as washing the day's heat into the sea at evening.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Oku no hosomichi",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694"
},
"supp-buson-lotus-fragrance": {
"id": "supp-buson-lotus-fragrance",
"poet": "Yosa Buson",
"form": "haiku",
"japanese": "蓮の香や水をはなるゝ莖二寸",
"translation": "Lotus fragrance—\ntwo inches of stem\nclear of the water.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "蕪村集 (as indexed by the museum)",
"source_url": "https://www2.city.kurashiki.okayama.jp/musnat/plant/bungakusakuhin/busonsyuu.htm",
"source_label": "Kurashiki Museum of Natural History: 蕪村集の植物",
"imagery": [
"lotus",
"water",
"fragrance",
"summer"
],
"notes": "New working translation. Source prints the old character 莖 (modern 茎). 二寸 is two traditional sun, approximately 6 cm; 'two inches' is a conventional approximate poetic rendering, not an exact metric conversion. Japanese also corroborated by https://www.manpukujitochigi.or.jp/zakki000063.html .",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784"
},
"supp-issa-dragonfly-river": {
"id": "supp-issa-dragonfly-river",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "蜻蛉の尻でなぶるや大井川",
"translation": "With its tail\na dragonfly teases\nthe Ōi River.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"source_url": "https://gakusyu.shizuoka-c.ed.jp/japanese/syou_34/koten/03/shizuoka02.html",
"source_label": "Shizuoka Prefecture Asunaro learning resources: 静岡ゆかりの俳句 2",
"imagery": [
"dragonfly",
"river",
"water surface"
],
"notes": "New working translation. Official educational page explicitly attributes all three displayed poems to Issa and explains the dragonfly touching the river with its small rear as though teasing the much larger river.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"supp-issa-owl-fireflies": {
"id": "supp-issa-owl-fireflies",
"poet": "Kobayashi Issa",
"form": "haiku",
"japanese": "梟や蛍蛍をよぶやうに",
"translation": "An owl—\nas though calling,\n“Fireflies! Fireflies!”",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "七番日記, Bunka 7 (1810)",
"source_url": "https://www.janis.or.jp/users/kyodoshi/issaku-11.htm",
"source_label": "一茶発句全集(11), summer animals: fireflies",
"imagery": [
"owl",
"fireflies",
"summer",
"night"
],
"notes": "New working translation. Source reads 梟や蛍 ~ をよぶやうに; its repetition symbol is expanded to 蛍蛍 here. Night is inferred from the owl/firefly scene; the Japanese does not explicitly contain the word night. Much closer to a nocturnal owl scene than Issa's first-autumn-morning poem.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shichiban nikki",
"author_japanese": "小林一茶",
"author_dates": "1763–1828"
},
"teika-01": {
"id": "teika-01",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "春のよの夢のうきはしとだえしてみねにわかるゝよこ雲のそら",
"translation": "On a spring night\nthe floating bridge of dreams\nbreaks off—\nclouds stretch across the sky,\nparting at the peaks.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 38,
"source_url": "https://www.wakapoetry.net/skks-i-38/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"dream",
"broken bridge",
"clouds",
"spring night"
],
"notes": "Keep the abrupt break; do not turn this into a moral about completing the puzzle.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 38"
},
"teika-02": {
"id": "teika-02",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "おほぞらはむめのにほひにかすみつゝくもりもはてぬ春のよの月",
"translation": "The wide sky\nhazes with plum fragrance;\nnot wholly clouded,\nthe moon\nof a spring night.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 40,
"source_url": "https://www.wakapoetry.net/skks-i-40/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"plum scent",
"haze",
"moon"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 40"
},
"teika-03": {
"id": "teika-03",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "むめの花にほひをうつす袖のうへにのきもる月のかげぞあらそふ",
"translation": "On my sleeves,\nsteeped in plum-blossom scent,\nmoonlight\nleaking through the eaves\nvies with that fragrance.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 44,
"source_url": "https://www.wakapoetry.net/skks-i-44/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"moonbeam",
"eaves",
"plum fragrance",
"sleeves"
],
"notes": "The competition between scent and light is in the poem; retain its synesthesia.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 44"
},
"teika-04": {
"id": "teika-04",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "しもまよふそらにしほれしかりがねのかへるつばさに春雨ぞふる",
"translation": "Geese once wearied\nin a sky adrift with frost—\non their wings\nas they return,\nspring rain falls.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 63,
"source_url": "https://www.wakapoetry.net/tag/teika/page/23/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"return",
"geese",
"frost",
"spring rain"
],
"notes": "The Japanese is verified on the tag page; the individual poem-page retrieval returned an internal error. The birds are geese, not swallows.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 63"
},
"teika-06": {
"id": "teika-06",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "さくら色の庭のはる風あともなしとはゞぞ人の雪とだにみん",
"translation": "The cherry-colored\nspring wind in the garden\nhas left no trace;\na visitor\nwould see only snow.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 134,
"source_url": "https://www.wakapoetry.net/skks-ii-134/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"garden",
"petals",
"erased traces",
"snow"
],
"notes": "Excellent literal garden pairing; the snow is a comparison for fallen blossoms.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 134"
},
"teika-07": {
"id": "teika-07",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "見わたせば花も紅葉もなかりけり浦のとまやの秋のゆふぐれ",
"translation": "Looking out—\nno blossoms,\nno colored leaves.\nA thatched hut on the shore\nin the autumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 363,
"source_url": "https://www.wakapoetry.net/skks-iv-363/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"absence",
"plainness",
"shore",
"hut",
"autumn dusk"
],
"notes": "A strong alternative finale: absence and austerity, rather than praise of an elaborate finished pattern.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 363"
},
"teika-08": {
"id": "teika-08",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "さむしろやまつよの秋の風ふけて月をかたしくうぢのはしひめ",
"translation": "On a narrow mat,\nin the autumn wind\nof a long night’s waiting,\nshe lies with only the moonlight—\nthe Lady of Uji Bridge.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 420,
"source_url": "https://www.wakapoetry.net/skks-iv-420/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"straw mat",
"waiting",
"moonlight",
"bridge"
],
"notes": "Katashiku carries the loneliness of lying alone; moonlight becomes bedding.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 420"
},
"teika-09": {
"id": "teika-09",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "たび人の袖ふきかへす秋風にゆふひさびしき山のかけはし",
"translation": "The autumn wind\nblows back\nthe traveler’s sleeves;\nlonely in the evening sun,\nthe cliffside bridge.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 953,
"source_url": "https://www.wakapoetry.net/skks-x-953/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"traveler",
"autumn wind",
"sunset",
"mountain footbridge"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 953"
},
"teika-11": {
"id": "teika-11",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "わするなよやどるたもとはかはるともかたみにしぼるよはの月かげ",
"translation": "Do not forget—\nthough the sleeves that held it\nchange with time—\nthe midnight moonlight\nwe wrung out together.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 891,
"source_url": "https://www.wakapoetry.net/skks-ix-891/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"parting",
"shared moonlight",
"sleeves",
"remembrance"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 891"
},
"teika-12": {
"id": "teika-12",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "たまゆらのつゆも涙もとゞまらずなき人こふるやどの秋風",
"translation": "Not for an instant\nwill dew or tears\nremain—\nautumn wind in the house\nwhere I mourn the dead.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 788,
"source_url": "https://www.wakapoetry.net/skks-viii-788/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"dew",
"tears",
"autumn wind",
"mourning",
"house"
],
"notes": "The headnote places this after his mother’s death; its grief should not be trivialized into a joke about failure.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 788"
},
"teika-13": {
"id": "teika-13",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "まつ人のふもとのみちはたえぬらんのきばのすぎに雪をもるなり",
"translation": "Has the road below\nclosed to the one I await?\nOn the cedar\nbeside my eaves\nthe snow grows heavy.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 672,
"source_url": "https://www.wakapoetry.net/skks-vi-672/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"blocked path",
"waiting",
"cedar",
"snow"
],
"notes": "The transmitted kana has 雪をもる; normalizing gives 雪おもる, snow grows heavy. Do not render this as a transitive act of piling snow.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 672"
},
"teika-14": {
"id": "teika-14",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "こまとめて袖うちはらふかげもなしさのゝわたりの雪のゆふぐれ",
"translation": "No shelter\nwhere I might halt my horse\nand brush my sleeves:\nthe ford at Sano\nin the snowy dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 671,
"source_url": "https://www.wakapoetry.net/skks-vi-671/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"no shelter",
"snow",
"crossing",
"horse",
"evening"
],
"notes": "Kage here is shelter, not the assertion that the speaker casts no shadow. Also verified in Japanese at https://hyakuninisshu.com/97.html .",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 671"
},
"teika-15": {
"id": "teika-15",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "時わかぬなみさへ色にいづみがははゝそのもりに嵐ふくらし",
"translation": "Even the waves,\nwhich know no seasons,\ntake on color—\na storm must be blowing\nthrough Izumi’s oak woods.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shinkokinwakashū",
"poem_number": 532,
"source_url": "https://www.wakapoetry.net/skks-v-532/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"waves",
"colored leaves",
"river",
"storm"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 532"
},
"teika-16": {
"id": "teika-16",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "しぐれつる眞屋の軒端の程なきに頓てさしいる月の影かな",
"translation": "After the shower,\nunder the cottage’s\nscant eaves,\nmoonlight\ncomes straight in.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Senzaiwakashū",
"poem_number": 414,
"source_url": "https://www.wakapoetry.net/szs-vi-414/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"shower",
"eaves",
"moonlight",
"shelter"
],
"notes": "Provisional: hodo naki is treated spatially, of the scant eaves; the poem also compresses the interval between rain and moonlight.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Senzaishū 414"
},
"teika-17": {
"id": "teika-17",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "冬來ては一夜ふたよをたま笹の葉わけの霜の處せきまで",
"translation": "Winter has come.\nOne night, then two:\namong the bamboo leaves\nfrost thickens\nuntil no room remains.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Senzaiwakashū",
"poem_number": 400,
"source_url": "https://www.wakapoetry.net/szs-vi-400/",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"bamboo grass",
"frost",
"coverage",
"winter"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Senzaishū 400"
},
"teika-19": {
"id": "teika-19",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "松風のひびきも色もひとつにてみどりに落つる谷川の水",
"translation": "The pine wind’s\necho and color\nare one:\nthe valley stream\nfalls green.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Shūigūsō",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"pine wind",
"stream",
"green",
"synesthesia"
],
"notes": "Collection inferred from the source’s explicit rule that unnumbered selections are from Shūigūsō; exact poem number not supplied.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shūi gusō"
},
"teika-20": {
"id": "teika-20",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "夕立のくもまの日影はれそめて山のこなたをわたる白鷺",
"translation": "Sunlight begins\nto clear the shower clouds;\na white egret\npasses on this side\nof the mountain.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Gyokuyōwakashū",
"poem_number": 416,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"egret",
"clearing shower",
"sunlight",
"mountain"
],
"notes": "The poem is daylight, so Heron’s step is a much closer fit than Night heron.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Gyokuyōshū 416"
},
"teika-21": {
"id": "teika-21",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "こしかたはみな面影にうかびきぬ行末てらせ秋の夜の月",
"translation": "Everything behind me\nfloats up\nin memory.\nLight what lies ahead,\nmoon of the autumn night.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Gyokuyōwakashū",
"poem_number": 688,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"past",
"future",
"moonlight",
"memory"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Gyokuyōshū 688"
},
"teika-22": {
"id": "teika-22",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "つくづくと明けゆく窓のともし火の有りやとばかりとふ人もなし",
"translation": "I watch the lamp\nas the window grows light.\nNo one asks\neven whether\nI am still here.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04",
"collection": "Gyokuyōwakashū",
"poem_number": 2167,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "千人万首: 藤原定家 (unannotated selection)",
"imagery": [
"lamp",
"dawn",
"solitude",
"unvisited house"
],
"notes": "The lamp and solitary speaker overlap; the translation preserves that indirectness.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Gyokuyōshū 2167"
},
"teika-23": {
"id": "teika-23",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "山のはの月まつ空のにほふより花にそむくる春のともし火",
"translation": "Waiting for the moon,\nthe mountain-edge sky\nbegins to glow;\nI turn the spring lamplight\naway from the flowers.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Gyokuyōwakashū",
"poem_number": 211,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"lamp",
"moonrise",
"flowers",
"light"
],
"notes": "",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Gyokuyōshū 211"
},
"teika-24": {
"id": "teika-24",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "吹きはらふ紅葉のうへの霧はれて峯たしかなるあらし山かな",
"translation": "Swept by the wind,\nthe mist over the red leaves clears;\nthe peak stands sure:\nArashiyama,\nStorm Mountain.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Shūigūsō; Kanpaku Sadaijin-ke hyakushu (1232), topic: prospect",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "千人万首: 藤原定家 (unannotated selection)",
"imagery": [
"mist clearing",
"autumn leaves",
"ridge",
"clarity"
],
"notes": "Exact anthology poem number not supplied by the consulted source. The source prints あらし山 in kana, which keeps the pun on arashi, storm, next to 吹きはらふ; the English glosses the name.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shūi gusō"
},
"teika-25": {
"id": "teika-25",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "風のうへに星のひかりは冴えながらわざともふらぬ霰をぞ聞く",
"translation": "In the wind above,\nstarlight\nis cold and clear.\nI listen to hail\ntoo slight to see falling.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shūigūsō ingai; Bunshū hyakushu (1218)",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/teika.html",
"source_label": "千人万首: 藤原定家 (unannotated selection)",
"imagery": [
"stars",
"wind",
"hail",
"listening"
],
"notes": "Exact poem number not supplied by the consulted source.",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shūi gusō ingai"
},
"teika-26": {
"id": "teika-26",
"poet": "Fujiwara no Teika",
"author_japanese": "藤原定家",
"author_dates": "1162–1241",
"form": "waka",
"japanese": "来ぬ人を松帆の浦の夕なぎに焼くやもしほの身もこがれつつ",
"translation": "Waiting for someone\nwho does not come—\nat Matsuho, in the evening calm,\nthey burn seaweed for salt;\nso does my body burn.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ogura Hyakunin isshu; Shinchokusenwakashū",
"poem_number": "97; 849",
"source_url": "https://hyakuninisshu.com/97.html",
"source_label": "Fujiwara no Teika n.d.",
"imagery": [
"waiting",
"evening calm",
"sea",
"salt fire",
"longing"
],
"notes": "Matsuho echoes matsu, to wait. The supplementary anthology number 849 is verified in the Asahi Teika text; a museum also confirms Hyakunin isshu 97 at https://www.meihaku.jp/hyakunin-isshu-kajin/kajin-fujiwarano-teika/ .",
"verification": "Japanese text, attribution, and collection identifier checked in the linked source; English is a provisional independent rendering.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Hyakunin isshu 97"
},
"waka-other-01": {
"id": "waka-other-01",
"poet": "Saigyō",
"form": "waka",
"japanese": "岩間とぢし氷も今朝はとけそめて苔の下水みちもとむらん",
"translation": "Ice that sealed the clefts\nstarts loosening this morning;\nunderneath the moss\nthe hidden water, surely,\nis searching for a path.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "7",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html",
"source_label": "千人万首: 西行, Japanese text and anthology reference",
"imagery": [
"moss",
"water",
"rocks",
"ice",
"spring",
"pathfinding"
],
"notes": "New five-line working translation from the cited Japanese. The conjectural ending -ramu is retained as 'surely'; this is an imagined search for a watercourse. The text is not in the principal Sankashū recension according to the source.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 7",
"author_japanese": "西行",
"author_dates": "1118–1190"
},
"waka-other-02": {
"id": "waka-other-02",
"poet": "Saigyō",
"form": "waka",
"japanese": "吉野山こぞのしをりの道かへてまだ見ぬかたの花をたづねむ",
"translation": "On Mount Yoshino\nI’ll leave the path I marked\nwith broken twigs last year\nand seek the blossoms\nin a place I haven’t seen.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "86",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html",
"source_label": "千人万首: 西行, Japanese text and anthology reference",
"imagery": [
"paths",
"branching",
"returning",
"cherry blossoms",
"mountains",
"spring"
],
"notes": "New working translation. Shiori means trail marks made by breaking branches; it is not a book's bookmark here. Particularly suitable for a level offering a new route through familiar ground.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 86",
"author_japanese": "西行",
"author_dates": "1118–1190"
},
"waka-other-03": {
"id": "waka-other-03",
"poet": "Saigyō",
"form": "waka",
"japanese": "道の辺に清水ながるる柳蔭しばしとてこそ立ちとまりつれ",
"translation": "Beside the road,\nclear water, willow shade—\nI had meant\nto stop here\nonly for a moment.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "262",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html",
"source_label": "千人万首: 西行, Japanese text and anthology reference",
"imagery": [
"road",
"water",
"willow",
"shade",
"rest",
"summer"
],
"notes": "New working translation. 'I had meant' carries the implication that the short stop became a longer stay. The cited text begins 道の辺に; some witnesses have 道の辺の.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 262",
"author_japanese": "西行",
"author_dates": "1118–1190"
},
"waka-other-04": {
"id": "waka-other-04",
"poet": "Saigyō",
"form": "waka",
"japanese": "ゆくへなく月に心のすみすみて果てはいかにかならむとすらむ",
"translation": "My heart grows clear,\nclearer still,\nendlessly in the moonlight.\nWhat will it become\nat last?",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04",
"collection": "Sankashū",
"poem_number": "353",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html",
"source_label": "千人万首: 西行, Sankashū text and numbering",
"imagery": [
"moon",
"clarity",
"mind",
"limitlessness",
"ending"
],
"notes": "New working translation. Number follows the Shinpen Kokka Taikan numbering identified by the source. Yukue naku can suggest directionlessness or absence of a limit.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Sankashū 353",
"author_japanese": "西行",
"author_dates": "1118–1190"
},
"waka-other-06": {
"id": "waka-other-06",
"poet": "Saigyō",
"form": "waka",
"japanese": "さびしさに堪へたる人のまたもあれな庵ならべむ冬の山里",
"translation": "If only someone else\ncould bear this solitude—\nwe’d set our huts\nside by side\nin the winter mountain village.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "627",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html",
"source_label": "千人万首: 西行, Japanese text and anthology references",
"imagery": [
"solitude",
"paired huts",
"winter",
"mountain village",
"companionship"
],
"notes": "New working translation. Also Sankashū 513 in the cited numbering. The wish is for company able to share solitude, not for solitude to be abolished.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 627",
"author_japanese": "西行",
"author_dates": "1118–1190"
},
"waka-other-07": {
"id": "waka-other-07",
"poet": "Fujiwara no Shunzei",
"form": "waka",
"japanese": "夕されば野辺の秋風身にしみて鶉鳴くなり深草の里",
"translation": "When evening comes,\nthe autumn wind across the fields\ncuts through me.\nQuail are calling\nin Fukakusa village.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Senzai Wakashū",
"poem_number": "259",
"source_url": "https://www.asahi-net.or.jp/~SG2H-ymst/yamatouta/sennin/syunzei2.html",
"source_label": "千人万首: 藤原俊成, Japanese text and anthology reference",
"imagery": [
"wind",
"fields",
"quail",
"dusk",
"autumn",
"village"
],
"notes": "New working translation. Fukakusa is a place name that also suggests deep grass. The source records the Ise monogatari allusion behind the quail; the English leaves that allusion implicit.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Senzaishū 259",
"author_japanese": "藤原俊成",
"author_dates": "1114–1204"
},
"waka-other-08": {
"id": "waka-other-08",
"poet": "Fujiwara no Shunzei",
"form": "waka",
"japanese": "石ばしる水の白玉数見えて清滝川にすめる月影",
"translation": "The white beads of water\ndashing on the rocks\ncan be counted,\nso clear the moonlight\non the Kiyotaki River.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Senzai Wakashū",
"poem_number": "284",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syunzei.html",
"source_label": "千人万首: 藤原俊成, Japanese text and anthology reference",
"imagery": [
"water",
"rocks",
"moonlight",
"droplets",
"clarity",
"counting"
],
"notes": "New working translation. White jewels are droplets of spray made distinct by moonlight; Kiyotaki is retained as a river name.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Senzaishū 284",
"author_japanese": "藤原俊成",
"author_dates": "1114–1204"
},
"waka-other-09": {
"id": "waka-other-09",
"poet": "Fujiwara no Shunzei",
"form": "waka",
"japanese": "ひとり見る池の氷にすむ月のやがて袖にもうつりぬるかな",
"translation": "Alone I watch\nthe moon clear\non the frozen pond,\nand soon it shines\nin my sleeve as well.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "640",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syunzei.html",
"source_label": "千人万首: 藤原俊成, Japanese text and anthology reference",
"imagery": [
"reflection",
"ice",
"pond",
"moon",
"solitude",
"tears"
],
"notes": "New working translation. The sleeve is wet with tears and so receives the reflected moon; this remains implicit, as in the Japanese. Utsuru combines transfer and reflection.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 640",
"author_japanese": "藤原俊成",
"author_dates": "1114–1204"
},
"waka-other-10": {
"id": "waka-other-10",
"poet": "Princess Shikishi",
"form": "waka",
"japanese": "山ふかみ春ともしらぬ松の戸にたえだえかかる雪の玉水",
"translation": "Deep in the mountains,\nmy pinewood door\ndoes not yet know it’s spring:\none drop, then another,\npearls of melting snow.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "3",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syokusi.html",
"source_label": "千人万首: 式子内親王, Japanese text and anthology reference",
"imagery": [
"threshold",
"door",
"snowmelt",
"mountains",
"spring",
"droplets"
],
"notes": "New working translation. Matsu no to may be a pine-board door or a gate made from pine branches. The poet's name is also read Shokushi.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 3",
"author_japanese": "式子内親王",
"author_dates": "d. 1201"
},
"waka-other-12": {
"id": "waka-other-12",
"poet": "Princess Shikishi",
"form": "waka",
"japanese": "かへりこぬ昔を今と思ひ寝の夢の枕ににほふ橘",
"translation": "I sleep, thinking\nthe past that cannot return\nis here again;\norange blossom scents\nthe pillow of my dream.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "240",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syokusi.html",
"source_label": "千人万首: 式子内親王, Japanese text and anthology reference",
"imagery": [
"returning",
"memory",
"citrus blossom",
"scent",
"dream",
"summer"
],
"notes": "New working translation. Tachibana is the native Japanese citrus; its blossom scent conventionally recalls someone from the past.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 240",
"author_japanese": "式子内親王",
"author_dates": "d. 1201"
},
"waka-other-13": {
"id": "waka-other-13",
"poet": "Eifukumon’in",
"form": "waka",
"japanese": "きりぎりす声はいづくぞ草もなきしらすの庭の秋の夜の月",
"translation": "A cricket—\nwhere is that voice?\nNo grass grows here\nin the white-gravel courtyard\nunder the autumn moon.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Fūga Wakashū",
"poem_number": "556",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html",
"source_label": "千人万首: 永福門院, Japanese text and anthology reference",
"imagery": [
"white gravel",
"garden",
"moon",
"cricket",
"absence",
"autumn"
],
"notes": "New working translation. Shirasu is a white-sand or white-gravel court. Classical kirigirisu denotes a cricket, not necessarily the insect called kirigirisu in modern Japanese. Especially close to the game's actual setting.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Fūgashū 556",
"author_japanese": "永福門院",
"author_dates": "1271–1342"
},
"waka-other-14": {
"id": "waka-other-14",
"poet": "Eifukumon’in",
"form": "waka",
"japanese": "たきつ瀬や岩もとしろくよる花はながるとすれどまたかへるなり",
"translation": "At the rapids,\nflowers gather white\nagainst a rock;\nthey start to drift away\nand then come back again.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Fūga Wakashū",
"poem_number": "255",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html",
"source_label": "千人万首: 永福門院, Japanese text and anthology reference",
"imagery": [
"water",
"rock",
"eddies",
"returning",
"fallen blossoms",
"spring"
],
"notes": "New working translation. The returning movement is explicit; an eddy explains it but is not named in the Japanese. Suitable for loops or the mirrored return.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Fūgashū 255",
"author_japanese": "永福門院",
"author_dates": "1271–1342"
},
"waka-other-15": {
"id": "waka-other-15",
"poet": "Eifukumon’in",
"form": "waka",
"japanese": "花の上にしばしうつろふ夕づく日入るともなしに影きえにけり",
"translation": "For a little while\nthe evening sunlight lingers\non the blossoms;\nwithout seeming to set,\nits light has disappeared.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Fūga Wakashū",
"poem_number": "199",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html",
"source_label": "千人万首: 永福門院, Japanese text and anthology reference",
"imagery": [
"twilight",
"blossoms",
"light",
"disappearance",
"spring"
],
"notes": "New working translation. The cited anthology is Fūgashū, not Gyokuyōshū; some popular pages misidentify it. The unnoticed loss of light supplies the action.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Fūgashū 199",
"author_japanese": "永福門院",
"author_dates": "1271–1342"
},
"waka-other-17": {
"id": "waka-other-17",
"poet": "Eifukumon’in",
"form": "waka",
"japanese": "ま萩ちる庭の秋風身にしみて夕日のかげぞ壁に消えゆく",
"translation": "Bush-clover flowers fall.\nThe garden’s autumn wind\ngoes through me;\nevening sunlight fades\nupon the wall.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Fūga Wakashū",
"poem_number": "478",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/eihuku.html",
"source_label": "千人万首: 永福門院, Japanese text and anthology reference",
"imagery": [
"garden",
"wall",
"wind",
"falling flowers",
"sunlight",
"autumn"
],
"notes": "New working translation. Hagi is bush clover; this is autumn blossom fall, not spring cherry blossom.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Fūgashū 478",
"author_japanese": "永福門院",
"author_dates": "1271–1342"
},
"waka-other-18": {
"id": "waka-other-18",
"poet": "Izumi Shikibu",
"form": "waka",
"japanese": "春霞たつやおそきと山川の岩間をくぐる音きこゆなり",
"translation": "Impatient, perhaps,\nfor the spring haze to rise,\nthe mountain stream\nworks its way between rocks;\nI hear the sound.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Goshūi Wakashū",
"poem_number": "13",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/izumi_n.html",
"source_label": "千人万首: 和泉式部, Japanese text and anthology reference",
"imagery": [
"water",
"rocks",
"spring haze",
"passage",
"sound"
],
"notes": "New working translation. The opening gives the water an apparent impatience at the haze's delay; the ending locates the perception in hearing.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Goshūishū 13",
"author_japanese": "和泉式部",
"author_dates": "fl. c. 1000"
},
"waka-other-21": {
"id": "waka-other-21",
"poet": "Jakuren",
"form": "waka",
"japanese": "さびしさはその色としもなかりけり槙立つ山の秋の夕暮",
"translation": "Solitude\nhas no color of its own.\nThe mountains\nstand thick with evergreens\nin the autumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": "361",
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/jakuren.html",
"source_label": "千人万首: 寂蓮法師, Japanese text and anthology reference",
"imagery": [
"solitude",
"colorlessness",
"evergreens",
"mountains",
"autumn dusk"
],
"notes": "New working translation. Maki here is a collective term for conifers such as cedar and cypress; translating it specifically as pine would be needlessly narrow. One of the three celebrated autumn-dusk poems.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Shinkokinshū 361",
"author_japanese": "寂蓮",
"author_dates": "c. 1139–1202"
},
"waka-other-23": {
"id": "waka-other-23",
"poet": "Jien",
"form": "waka",
"japanese": "旅の世にまた旅寝して草まくら夢のうちにも夢をみるかな",
"translation": "On this journey of a life\nI sleep away from home again,\ngrass for my pillow—\nand even within a dream\nI dream.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Senzai Wakashū",
"poem_number": "533",
"source_url": "https://www.asahi-net.or.jp/~SG2H-YMST/yamatouta/sennin/jien.html",
"source_label": "千人万首: 慈円, Japanese text and anthology reference",
"imagery": [
"journey",
"rest",
"grass pillow",
"dream",
"nested worlds"
],
"notes": "New working translation. Life itself is treated as a journey and a dream, with literal traveling and dreaming nested within it.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Senzaishū 533",
"author_japanese": "慈円",
"author_dates": "1155–1225"
},
"waka-other-25": {
"id": "waka-other-25",
"poet": "Ki no Tsurayuki",
"form": "waka",
"japanese": "人はいさ心も知らずふるさとは花ぞ昔の香ににほひける",
"translation": "As for people—\nI cannot know their hearts.\nHere at this old place,\nthe plum blossoms\nstill give their old scent.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Kokin Wakashū",
"poem_number": "42",
"source_url": "https://www.karuta.or.jp/karuta-everyday/2815/",
"source_label": "All Japan Karuta Association: 人はいさ, Japanese poem and original anthology reference",
"imagery": [
"plum",
"scent",
"returning",
"memory",
"unchanged place",
"spring"
],
"notes": "New working translation. Also Ogura Hyakunin Isshu 35. The headnote identifies the blossoms as plum and the old place as a previously visited lodging; furusato need not mean the poet's birthplace.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Kokinshū 42",
"author_japanese": "紀貫之",
"author_dates": "c. 872–945"
},
"waka-other-26": {
"id": "waka-other-26",
"poet": "Ōshikōchi no Mitsune",
"form": "waka",
"japanese": "心あてに折らばや折らむ初霜のおきまどはせる白菊の花",
"translation": "If I pick one,\nit must be by guess—\nwhite chrysanthemums\nlost in\nthe first frost.",
"translation_credit": "Working translation by GPT-6 Astra (2026), revised by GPT-6 Astra (review pass) and Claude, 2026-10-04",
"collection": "Kokin Wakashū",
"poem_number": "277",
"source_url": "https://www.karuta.or.jp/karuta-everyday/2791/",
"source_label": "All Japan Karuta Association: 心あてに, Japanese poem and original anthology reference",
"imagery": [
"frost",
"white flowers",
"chrysanthemums",
"concealment",
"indistinguishability",
"autumn"
],
"notes": "New working translation. Also Ogura Hyakunin Isshu 29. The conceit is that frost and white petals cannot readily be distinguished.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Kokinshū 277",
"author_japanese": "凡河内躬恒",
"author_dates": "fl. c. 900"
},
"waka-other-28": {
"id": "waka-other-28",
"poet": "Harumichi no Tsuraki",
"form": "waka",
"japanese": "山川に風のかけたるしがらみは流れもあへぬもみぢなりけり",
"translation": "Across the mountain stream\nthe wind has built a weir:\nfallen autumn leaves\nthat could not\nmake their way downstream.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Kokin Wakashū",
"poem_number": "303",
"source_url": "https://www.karuta.or.jp/karuta-everyday/2803/",
"source_label": "All Japan Karuta Association: 山川に, Japanese poem and original anthology reference",
"imagery": [
"stream",
"wind",
"barrier",
"leaves",
"autumn",
"blocked passage"
],
"notes": "New working translation. Also Ogura Hyakunin Isshu 32. Shigarami is a weir or fence across a stream; it is formed here by a jam of leaves, not a constructed object.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "Kokinshū 303",
"author_japanese": "春道列樹",
"author_dates": "d. 920"
},
"waka-other-29": {
"id": "waka-other-29",
"poet": "Ryōkan",
"form": "waka",
"japanese": "山かげの岩間をつたふ苔水のかすかに我はすみわたるかも",
"translation": "In the mountain shade,\nwater threads the moss\nbetween the rocks;\nlike that faint flow\nI quietly dwell.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ryōkan Oshō kashū (collected waka; cited edition edited by Sōma Gyofū)",
"source_url": "https://ryoukan-w.info/?page_id=1315",
"source_label": "良寛ワールド: 良寬 珠玉の言葉 11~20",
"imagery": [
"moss",
"water",
"rocks",
"shade",
"solitude",
"quiet dwelling"
],
"notes": "New working translation. No poem number verified. Sumi combines dwelling and being clear; the English prioritizes dwelling, with the clear-water implication retained by the image.",
"reverified": "yes, 2026-10-04: checked against source_url, which prints it in modern kana as 「やまかげの 岩間を伝う 苔水の かすかに我は すみわたるかも」",
"display_source": "",
"author_japanese": "良寛",
"author_dates": "1758–1831"
},
"waka-other-30": {
"id": "waka-other-30",
"poet": "Ryōkan",
"form": "waka",
"japanese": "形見とて何か残さむ春は花山ほととぎす秋はもみぢ葉",
"translation": "What could I leave\nto remember me by?\nSpring blossoms,\nthe mountain cuckoo,\nautumn leaves.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Ryōkan zenshū, lower volume (Tokyo Sōgensha, 1959), p. 195; surviving calligraphy",
"poem_number": "1238",
"source_url": "https://crd.ndl.go.jp/reference/entry/index.php?id=1000101479&page=ref_view",
"source_label": "Osaka Prefectural Central Library, NDL Collaborative Reference Database: Ryōkan textual variants",
"imagery": [
"inheritance",
"blossoms",
"cuckoo",
"autumn leaves",
"seasons",
"farewell"
],
"notes": "New working translation. The library documents this precise mountain-cuckoo version as no. 1238, and a summer-cuckoo variant as no. 1239. Do not silently blend the versions or present this as a securely dated deathbed composition.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"display_source": "",
"author_japanese": "良寛",
"author_dates": "1758–1831"
},
"new-basho-sweep-garden": {
"id": "new-basho-sweep-garden",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "庭掃て出ばや寺に散柳",
"translation": "Let me sweep the garden\nbefore I go—\nwillow leaves fall in the temple.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Zenshōji, journey of 1689",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno34.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 曾良との別れ",
"display_source": "Oku no hosomichi",
"notes": "Leaving the temple where he had lodged, Bashō sweeps its garden as a guest's thanks; the young monks had run after him with paper and inkstone. The 1903 Zenshū prints 精舍 for 寺.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"waka-other-11": {
"id": "waka-other-11",
"poet": "Princess Shikishi",
"form": "waka",
"japanese": "窓ちかき竹の葉すさぶ風の音にいとどみじかきうたたねの夢",
"translation": "By the window,\nwind sets bamboo leaves\nrustling;\nmy dozing dream\nis shorter still.",
"translation_credit": "Working translation by GPT-6 Astra (2026); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": 256,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/syokusi.html",
"source_label": "千人万首: 式子内親王, Japanese text and anthology reference",
"imagery": [
"bamboo",
"wind",
"window",
"dream",
"rest",
"summer"
],
"notes": "New working translation. The sound interrupts a light sleep; no waking character or specific dream content has been added.",
"author_japanese": "式子内親王",
"author_dates": "d. 1201",
"display_source": "Shinkokinshū 256",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (新古256, headnote 百首の歌たてまつりし時)"
},
"new-tomonori-hi33": {
"id": "new-tomonori-hi33",
"poet": "Ki no Tomonori",
"author_japanese": "紀友則",
"author_dates": "d. c. 905",
"form": "waka",
"japanese": "ひさかたの 光のどけき 春の日に しづ心なく 花の散るらむ",
"translation": "In the tranquil light\nof a spring day\nunder the wide sky—\nwhy, with no calm in their hearts,\ndo the blossoms fall?",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "Ogura Hyakunin Isshu; Kokin Wakashū 84",
"poem_number": 33,
"source_url": "https://www.bunkanet.jp/manabi/hyakunin-isshu-karuta/element/33/",
"source_label": "Bunka Net n.d., Hyakunin Isshu 33",
"display_source": "Hyakunin isshu 33",
"notes": "しづ心 is a calm, settled heart; the garden Calm scores exactly that.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-ryokan-new-pond": {
"id": "new-ryokan-new-pond",
"poet": "Ryōkan",
"author_japanese": "良寛",
"author_dates": "1758–1831",
"form": "haiku",
"japanese": "新池や蛙とびこむ音もなし",
"translation": "The new pond—\na frog jumps in;\nnot a sound.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "Ryōkan zenkushū (ed. Tanikawa Toshirō), as cited by the source",
"poem_number": null,
"source_url": "https://sakuramitih31.livedoor.blog/archives/41933640.html",
"additional_source_url": "https://idea1616.com/ryokan-haiku/",
"source_label": "名歌鑑賞: 新池や蛙とびこむ音もなし (blog, citing 谷川敏朗『良寛全句集』)",
"display_source": "",
"notes": "Ryōkan's answer to Bashō's old pond: the new pond has no sound. Attribution is the traditional one; the text was checked against two secondary pages, not a critical edition.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (secondary sources)"
},
"new-basho-summer-room": {
"id": "new-basho-summer-room",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "山も庭も動き入るゝや夏座敷",
"translation": "Mountain and garden\ncome moving in—\nthe summer room.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "芭蕉俳句全集 (1903), 夏・夏座敷; composed 1689 at Kurobane",
"poem_number": null,
"source_url": "https://ja.wikisource.org/wiki/芭蕉俳句全集",
"source_label": "Bashō haiku zenshū, edited by Ōtsuka Kōzan (1903), summer section",
"display_source": "Kurobane, 1689",
"notes": "A greeting verse for his host's open summer room at Kurobane, on the Oku no hosomichi journey: the view enters the room.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-hida": {
"id": "new-basho-hida",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "涼しさを飛騨の工が指図かな",
"translation": "Coolness—\ndrawn to the plan\nof a Hida carpenter.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "Letter to Sugiyama Sanpū, Genroku 7, intercalary fifth month, day 11 (1694)",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/letter/sanpu1.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: letter to Sanpū (1)",
"display_source": "letter of 1694",
"notes": "指図 is an architect's drawing or direction; the carpenters of Hida were the proverbial master builders. Bashō later preferred the variant 涼しさの指図に見ゆる住まゐかな.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-hikarido": {
"id": "new-basho-hikarido",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "五月雨の降のこしてや光堂",
"translation": "The summer rains—\ndid they spare only this?\nThe Hall of Light.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Hiraizumi, journey of 1689",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno20.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 平泉",
"display_source": "Oku no hosomichi",
"notes": "The Konjikidō at Chūsonji, kept whole for five centuries while everything around it fell; 降りのこす is to leave something unrained-on.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-buson-dew-thorns": {
"id": "new-buson-dew-thorns",
"poet": "Yosa Buson",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784",
"form": "haiku",
"japanese": "白露や茨の刺にひとつづつ",
"translation": "White dew—\non each thorn of the bramble,\none drop.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū), autumn",
"poem_number": null,
"source_url": "https://idea1616.com/buson/",
"additional_source_url": "https://ameblo.jp/mtada99/entry-12474301599.html",
"source_label": "与謝蕪村の俳句 102選 (list), and a commentary page",
"display_source": "Buson kushū",
"notes": "Checked against two secondary pages (the museum and bonjin lists used elsewhere do not carry it).",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (secondary sources)"
},
"new-buson-fuji-leaves": {
"id": "new-buson-fuji-leaves",
"poet": "Yosa Buson",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784",
"form": "haiku",
"japanese": "不二ひとつうづみ残して若葉かな",
"translation": "Young leaves—\nthey have buried everything\nbut Fuji.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "蕪村句集 (Buson kushū), summer",
"poem_number": null,
"source_url": "https://bonjin-ultra.com/buson.htm",
"source_label": "bonjin-ultra: 蕪村の俳句, summer",
"display_source": "Buson kushū",
"notes": "うづみ残す: to leave one thing unburied.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-ishiyama": {
"id": "new-basho-ishiyama",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "石山の石より白し秋の風",
"translation": "Whiter\nthan the stones of Stone Mountain—\nthe autumn wind.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Natadera, journey of 1689",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno33.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 那谷",
"display_source": "Oku no hosomichi",
"notes": "Autumn's color is white in the five-phase scheme; the wind outdoes the white rocks of Nata (or of Ishiyama) in it.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-issa-snail": {
"id": "new-issa-snail",
"poet": "Kobayashi Issa",
"author_japanese": "小林一茶",
"author_dates": "1763–1828",
"form": "haiku",
"japanese": "かたつぶりそろそろ登れ富士の山",
"translation": "Little snail,\nslowly, slowly climb—\nMount Fuji.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "一茶発句集 (Issa hokkushū), Bunsei edition (1829, posthumous)",
"poem_number": null,
"source_url": "https://crd.ndl.go.jp/reference/entry/index.php?page=ref_view&id=1000180048",
"source_label": "NDL Collaborative Reference Database: the collections that carry the snail verse",
"display_source": "Issa hokkushū (1829)",
"notes": "Not in Issa's own diaries; first printed two years after his death (the Kaei edition has 不二の山). Text as the Bunsei edition prints it, per the reference record.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-one-field": {
"id": "new-basho-one-field",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "田一枚植て立去る柳かな",
"translation": "One field planted—\nand I leave\nthe willow.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), the Yugyō willow, journey of 1689",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno07.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 遊行柳",
"display_source": "Oku no hosomichi",
"notes": "Under Saigyō's willow; who plants the field (the women, or Bashō's party) is left open, as is who leaves.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-buson-dry-spring": {
"id": "new-buson-dry-spring",
"poet": "Yosa Buson",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784",
"form": "haiku",
"japanese": "柳ちり清水かれ石ところどころ",
"translation": "Willow leaves fallen,\nthe spring run dry,\nstones here and there.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "蕪村集 (as indexed by the museum)",
"poem_number": null,
"source_url": "https://www2.city.kurashiki.okayama.jp/musnat/plant/bungakusakuhin/busonsyuu.htm",
"source_label": "Kurashiki Museum of Natural History: 蕪村集の植物",
"display_source": "",
"notes": "An answer to Saigyō's roadside willow and its clear water (Stepping stone): the water gone, the stones left.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-ryokan-thief-moon": {
"id": "new-ryokan-thief-moon",
"poet": "Ryōkan",
"author_japanese": "良寛",
"author_dates": "1758–1831",
"form": "haiku",
"japanese": "盗人に取り残されし窓の月",
"translation": "The thief\nleft it behind:\nthe moon in the window.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "Ryōkan's hokku; the anecdote of the thief at his hut",
"poem_number": null,
"source_url": "https://nichi-zen.site/remaining-moon/",
"additional_source_url": "https://www.jomo-news.co.jp/articles/-/5483",
"source_label": "日常実践の禅: 盗人に取り残されし窓の月",
"display_source": "",
"notes": "The thief found nothing worth taking (one telling has Ryōkan roll over so the man could take his bedding). Attribution is the traditional one; checked against secondary pages.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url (secondary sources)"
},
"new-manyo-1068": {
"id": "new-manyo-1068",
"poet": "Kakinomoto no Hitomaro",
"author_japanese": "柿本人麻呂歌集",
"author_dates": "fl. c. 680–700",
"form": "waka",
"japanese": "天の海に雲の波立ち月の船星の林に榜ぎ隠る見ゆ",
"translation": "On the sea of heaven\nthe waves of cloud rise;\nthe boat of the moon\nrows into the forest of stars\nand is hidden from sight.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "Man'yōshū, book 7 (from the Hitomaro Collection)",
"poem_number": 1068,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/manyok/manyok07.html",
"source_label": "訓読万葉集 巻7 (Yamato Uta), after Kamochi Masazumi's 萬葉集古義",
"display_source": "Man'yōshū 1068",
"notes": "Headed 天を詠める and marked as from the Kakinomoto no Hitomaro Collection, whose poems are not all his; the source prints 榜ぎ for 漕ぎ.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-sea-darkens": {
"id": "new-basho-sea-darkens",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "海くれて鴨のこゑほのかに白し",
"translation": "The sea darkens;\nthe voices of the ducks\nfaintly white.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "野ざらし紀行 (Nozarashi kikō), Atsuta, winter 1684",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/nozarasi/nozara23.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: Nozarashi kikō, 海邊に日暮して",
"display_source": "Nozarashi kikō",
"notes": "Headnote 海邊に日暮して. A sound given a color: the source calls it one of his finest verses.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-moon-sand": {
"id": "new-basho-moon-sand",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "月清し遊行のもてる砂の上",
"translation": "The moon is clear\non the sand\nthe pilgrim monk carried.",
"translation_credit": "New translation by Claude, 2026-10-04",
"collection": "おくのほそ道 (Oku no hosomichi), Tsuruga (Kehi shrine), journey of 1689",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/okunohosomichi/okuno38.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: Oku no hosomichi, 敦賀",
"display_source": "Oku no hosomichi",
"notes": "遊行 is the Yugyō Shōnin, head of the Ji sect: the second, Ta'a, carried sand himself to fill the shrine's muddy grounds, and his successors keep the rite (遊行の砂持ち). The 1903 Zenshū prints 月淸し遊行の持てる砂の上.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-shiki-bridge-fish": {
"id": "new-shiki-bridge-fish",
"poet": "Masaoka Shiki",
"author_japanese": "正岡子規",
"author_dates": "1867–1902",
"composed": 1895,
"form": "haiku",
"japanese": "橋蹈めば魚沈みけり春の水",
"translation": "I step on the bridge—\nthe fish sink.\nSpring water.",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass), revised by Claude, 2026-10-04",
"collection": "獺祭書屋俳句帖抄 (Dassai shooku haiku chō shō), spring 1895",
"poem_number": null,
"source_url": "https://mukei-r.net/poem-masaoka/00-haiku-dassai1.htm",
"source_label": "獺祭書屋俳句帖抄 (transcription), 明治28年 春",
"display_source": "1895",
"notes": "The source prints 沈みけり (Astra's draft had the fish sink \"deeper\"). Composed 1895; the poet died in 1902, so this is the one poem whose author outlived 1900.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-fushimi-lotus-dew": {
"id": "new-fushimi-lotus-dew",
"poet": "Emperor Fushimi",
"author_japanese": "伏見院",
"author_dates": "1265–1317",
"form": "waka",
"japanese": "こぼれ落つる池のはちすの白露は浮葉の玉とまたなりにけり",
"translation": "Dew spills\nfrom the pond's lotus leaves;\non the floating leaves\nit becomes\na jewel again.",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass), revised by Claude, 2026-10-04",
"collection": "Gyokuyō Wakashū (headnote: 百首御歌の中に、蓮を)",
"poem_number": null,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html",
"source_label": "和歌歳時記: 蓮葉 (Yamato Uta)",
"display_source": "Gyokuyōshū",
"notes": "Lotus, not water lily; the number in the Gyokuyōshū is not given by the source.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-tamemasa-carp-lotus": {
"id": "new-tamemasa-carp-lotus",
"poet": "Reizei Tamemasa",
"author_japanese": "冷泉為尹",
"author_dates": "1361–1417",
"form": "waka",
"japanese": "池水に藻臥しの鮒や乱るらん蓮のうき葉のゆるぎ立ちぬる",
"translation": "Hidden in the pondweed,\nare the carp stirring?\nThe lotus leaves\nfloating on the water\nhave begun to tremble.",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04",
"collection": "Tamemasa senshu (為尹千首), topic: pond lotus",
"poem_number": null,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/saijiki/hasunoha.html",
"source_label": "和歌歳時記: 蓮葉 (Yamato Uta)",
"display_source": "Tamemasa senshu",
"notes": "鮒 is the crucian carp. Astra credited the poem to Reizei Tamehide; the source gives 為尹 (Tamemasa, Tamehide's grandson).",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-grass-hut": {
"id": "new-basho-grass-hut",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "草の戸も住替る代ぞひなの家",
"translation": "Even this grass hut\nchanges hands—\na house of dolls.",
"collection": "Oku no hosomichi (opening hokku)",
"poem_number": null,
"source_url": "https://basho-yamadera.com/en/yamadera/kusanotomo",
"imagery": [
"grass hut",
"new occupants",
"dolls",
"change"
],
"notes": "",
"verification": "",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass), revised by Claude, 2026-10-04",
"display_source": "Oku no hosomichi"
},
"waka-other-05": {
"id": "waka-other-05",
"poet": "Saigyō",
"form": "waka",
"japanese": "心なき身にもあはれは知られけり鴫たつ沢の秋の夕暮",
"translation": "Even one like me,\nwho has set his heart aside,\nfeels it after all—\na snipe lifts from the marsh\nat autumn dusk.",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04",
"collection": "Shin Kokin Wakashū",
"poem_number": 362,
"source_url": "https://www.asahi-net.or.jp/~sg2h-ymst/yamatouta/sennin/saigyo.html",
"source_label": "千人万首: 西行, Japanese text and anthology references",
"imagery": [
"marsh",
"bird",
"autumn",
"dusk",
"detachment",
"feeling"
],
"notes": "New working translation. Also Sankashū 470 in the cited numbering. Kokoro naki can mean a renunciant freed of attachments or, more modestly, someone supposedly insensitive to beauty; this translation chooses the renunciant reading. One of the three celebrated autumn-dusk poems.",
"author_japanese": "西行",
"author_dates": "1118–1190",
"display_source": "Shinkokinshū 362",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-buson-dancers": {
"id": "new-buson-dancers",
"poet": "Yosa Buson",
"author_japanese": "与謝蕪村",
"author_dates": "1716–1784",
"form": "haiku",
"japanese": "四五人に月落かかるおどりかな",
"translation": "Four or five dancers—\nthe moon\nfalls toward them.",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04",
"collection": "Inscribed on his painting 踊図 (Dancers; Agency for Cultural Affairs)",
"poem_number": null,
"source_url": "https://www.kyuhaku.jp/exhibition/exhibition_s50.html",
"source_label": "Kyushu National Museum: 王羲之と日本の書 (exhibit list)",
"display_source": "on his painting 踊図",
"notes": "The Bon dance; 月落ちかかる is the moon sinking low over the dancers.",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
},
"new-basho-moon-clouds": {
"id": "new-basho-moon-clouds",
"poet": "Matsuo Bashō",
"author_japanese": "松尾芭蕉",
"author_dates": "1644–1694",
"form": "haiku",
"japanese": "雲折々人をやすむる月見かな",
"translation": "Watching the moon—\nevery so often a cloud\nlets us rest.",
"translation_credit": "Working translation by GPT-6 Astra (2026, review pass); reviewed by Claude, 2026-10-04",
"collection": "春の日 (Haru no hi), 1686",
"poem_number": null,
"source_url": "https://www2.yamanashi-ken.ac.jp/~itoyo/basho/shitibusyu/harunohi.htm",
"source_label": "Yamanashi Prefectural University, Bashō database: 俳諧七部集, 春の日",
"display_source": "Haru no hi (1686)",
"notes": "",
"reverified": "yes, 2026-10-04: Japanese text and attribution checked against source_url"
}
}
}poems/astra-samon-poem-selection.json
The sounds: the recordings' credits and their encoder (the synthesis is in the prototype) (sound/)
sound/README.md
# Sound
The page's sounds are of two kinds.
The bamboo pipe's drips, its hollow knock as it tips, the _bonshō_ at the close of a world, the seal's _tok_, the wind
(a bed with gusts), the birds by day and crickets by night (by the local clock), the footsteps, and the rake's scrape (a
looping synthesized crackle through a bandpass, driven frame by frame by the reveal's stroke speed, with a sideways rasp in
bends) are synthesized in the page (the `SND` block in `proto/samon.template.html`: decaying sines, filtered noise and
crackle, a short feedback delay for the basin's echo). They cost nothing to carry. `renderSound` (a test hook) renders any
sequence of them offline to WAV, which is how the tests check that each is audible and how they were listened to. A volume
slider and a haptics switch (`navigator.vibrate`, 8 ms per cell, shown only where it exists) sit beside the Sound switch;
a speaker button in the header (and the M key) is the same switch. The page sets `navigator.audioSession.type = "ambient"`
where that API exists (Safari 17), registers Media Session pause/stop/play handlers, and shows the speaker in red when the
browser keeps the context from running after a gesture (`soundBlocked`: Firefox's `getAutoplayPolicy("audiocontext")` saying
"disallowed", or a context still not running 1.5 s after the gesture). There is no CSS media query for a sound preference.
The shakuhachi is real: recordings in the public domain, listed in `tracks.json` with their credits and sources.
`make_sound_bundle.py` measures each one's loudness (EBU R128), brings it to −20 LUFS with a static gain (no dynamics
processing), encodes it as mono MP3 (LAME VBR 6; MP3 because desktop Safari still does not play Opus in Ogg), and writes
`out/sound_pieces.json`, which `proto/build.py` and `standalone/build_standalone.py` write into the page as inert
`<script type="text/plain" class="piece">` elements whose text is a `data:audio/mpeg;base64,...` URL, with the credits in
`data-` attributes. The page reads them (`loadPieces`) and plays one after a world's bell, or now and then (never within
a quarter-hour of the last), through a `MediaElementSource` with a 3 s fade in and a 4 s fade out; the scroll of poems
credits each with a link to its source.
The data: URLs are also what a SingleFile/gwtar step consumes: Gwern's `deconstruct_singlefile.php` moves every data: URL
out of the page into an appended archive and leaves a (quoted) path in its place. `samonAsset` in the template resolves a
piece's text whether it is still a data: URL, a plain path beside an extracted page, or a member of a gwtar archive (through
the gwtar loader's own globals, in both of its modes); the single-file edition's code .zip travels the same way.
Originals are not in this tree (the first is 3.6 MB and on Commons): to rebuild, put the files named by `tracks.json`
under `sound/originals/` and run `python3 sound/make_sound_bundle.py`, then the two builds.
Credits so far: _Shika no Tōne_ (鹿の遠音), Araki Kodō III, Victor 13029, 1925–35;
https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg (public domain; de-noised by Torsodog, 2008).
sound/make_sound_bundle.py — Encode the shakuhachi recordings for the page.
#!/usr/bin/env python3
"""Encode the shakuhachi recordings for the page.
Reads sound/tracks.json; for each track, measures the integrated loudness (EBU R128), brings it to the target with a
static gain (no dynamics processing: honkyoku lives on its silences), and encodes it as mono MP3 (universally playable:
Opus in Ogg is still not played by desktop Safari). Writes out/sound/<file> for listening and out/sound_pieces.json,
which proto/build.py and standalone/build_standalone.py write into the page as data: URLs in inert
<script type="text/plain" class="piece"> elements; a gwtar build (sound/gwtar/) then moves those into its archive.
Usage: python3 sound/make_sound_bundle.py [--quality 6] [--rate 32000]
Requires ffmpeg with libmp3lame.
"""
import argparse, base64, json, os, re, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPEC = os.path.join(ROOT, "sound", "tracks.json")
OUT_DIR = os.path.join(ROOT, "out", "sound")
OUT_JSON = os.path.join(ROOT, "out", "sound_pieces.json")
def run(cmd):
return subprocess.run(cmd, check=True, capture_output=True, text=True)
def probe(path):
r = run(["ffprobe", "-hide_banner", "-loglevel", "error", "-show_entries", "format=duration", "-of", "json", path])
return float(json.loads(r.stdout)["format"]["duration"])
def loudness(path):
"""Integrated loudness (LUFS) and true peak (dBTP) of a file, from ffmpeg's loudnorm measurement pass."""
r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", path, "-af", "loudnorm=print_format=json", "-f", "null", "-"],
capture_output=True, text=True, check=True)
m = re.search(r"\{.*\}", r.stderr, re.S)
j = json.loads(m.group(0))
return float(j["input_i"]), float(j["input_tp"])
def encode(track, target_lufs, quality, rate):
src = os.path.join(ROOT, track["input"])
dst = os.path.join(OUT_DIR, track["file"])
lufs, tp = loudness(src)
gain = target_lufs - lufs
if tp + gain > -1.0: # a static gain that would clip is capped; the piece is then a little quieter than the target
gain = -1.0 - tp
run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", src, "-ac", "1", "-ar", str(rate),
"-af", f"volume={gain:.2f}dB", "-codec:a", "libmp3lame", "-q:a", str(quality), "-id3v2_version", "0", "-write_xing", "1", dst])
dur = probe(dst)
out_lufs, out_tp = loudness(dst)
print(f"{track['file']}: {dur:.0f} s, {os.path.getsize(dst):,} bytes; {lufs:.1f} LUFS → {out_lufs:.1f} LUFS (gain {gain:+.1f} dB, peak {out_tp:.1f} dBTP)")
return dst, dur
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--quality", type=int, default=6, help="LAME VBR quality (0 best … 9 smallest); 6 ≈ 55 kbps mono")
ap.add_argument("--rate", type=int, default=32000, help="sample rate (Hz)")
a = ap.parse_args()
spec = json.load(open(SPEC))
os.makedirs(OUT_DIR, exist_ok=True)
pieces = []
for tr in spec["tracks"]:
for k in ("input", "file", "title", "player", "license", "source"):
if not tr.get(k):
sys.exit(f"track without {k}: {tr}")
dst, dur = encode(tr, spec.get("target_lufs", -20), a.quality, a.rate)
data = open(dst, "rb").read()
pieces.append({"file": tr["file"], "mime": "audio/mpeg", "bytes": len(data), "dur": round(dur, 1), "title": tr["title"], "player": tr["player"],
"license": tr["license"], "source": tr["source"], "gain": tr.get("gain", 0.35), "b64": base64.b64encode(data).decode()})
json.dump(pieces, open(OUT_JSON, "w"), ensure_ascii=False)
print(f"{len(pieces)} piece(s) → {OUT_JSON} ({os.path.getsize(OUT_JSON):,} bytes)")
if __name__ == "__main__":
main()
sound/tracks.json
{
"about": "The shakuhachi recordings carried in the page (sound/make_sound_bundle.py encodes them; the builders write them into the page as data: URLs in inert <script class=piece> elements, which a gwtar build moves into its archive). Every recording must be public domain or freely licensed, and credited on the scroll of poems with its source.",
"target_lufs": -20,
"tracks": [
{
"input": "sound/originals/Shikanotoone_new.ogg",
"file": "shika-no-tone-araki-kodo-iii.mp3",
"title": "Shika no Tōne (鹿の遠音, The Distant Cry of Deer)",
"player": "Araki Kodō III (1879–1935), Victor 13029, recorded 1925–35; noise reduced by Torsodog, 2008",
"license": "public domain: a Japanese phonogram whose neighbouring rights have expired, and a derivative released as PD by its maker",
"source": "https://commons.wikimedia.org/wiki/File:Shikanotoone_new.ogg",
"gain": 0.35,
"notes": "Honkyoku of the Kinko school, traditionally for two shakuhachi answering each other as deer in autumn. The Commons original is Ogg Vorbis, 6 min 15 s, 3.57 MB, two identical channels (L−R at −76 dB), −15.2 LUFS integrated, LRA 18 LU."
}
]
}
Data: levels, experiment results, logs, and the campaign cache (out/)
out/table1.log
{'size': '3x3', 'rakings': 8, 'complete': 1, 'par': 4, 'at_par': 2, 'median': 5, 'max': 6, 'wide': 2, 'wide_turns': [(4, 1), (5, 1)]}
{'size': '4x4', 'rakings': 52, 'complete': 1, 'par': 6, 'at_par': 4, 'median': 8, 'max': 13, 'wide': 4, 'wide_turns': [(6, 1), (7, 1), (8, 1), (9, 1)]}
{'size': '5x5', 'rakings': 824, 'complete': 1, 'par': 8, 'at_par': 10, 'median': 13, 'max': 20, 'wide': 4, 'wide_turns': [(8, 1), (9, 1), (10, 1), (11, 1)]}
{'size': '6x6', 'rakings': 22144, 'complete': 1, 'par': 10, 'at_par': 24, 'median': 20, 'max': 31, 'wide': 6, 'wide_turns': [(10, 1), (11, 1), (12, 1), (13, 1), (14, 1), (15, 1)]}
{'size': '7x7', 'rakings': 1510446, 'complete': 1, 'par': 12, 'at_par': 66, 'median': 27, 'max': 42, 'wide': 6, 'wide_turns': [(12, 1), (13, 1), (14, 1), (15, 1), (16, 1), (17, 1)]}
{'size': '6x8', 'rakings': 899432, 'complete': 1, 'par': 11, 'at_par': 66, 'median': 26, 'max': 42, 'wide': 8, 'wide_turns': [(11, 1), (13, 1), (14, 1), (15, 2), (16, 1), (17, 1), (18, 1)]}
out/table2.log
k=0 n=300 parity-ok=0.57 | one stroke: solvable 0.570 unique 0.000 median 100000 capped 171 | no U-turns at all: solvable 0.000 unique 0.000 median 0 capped 0 | wide rake: solvable 0.570 unique 0.000 median 6 capped 0
k=2 n=300 parity-ok=0.43 | one stroke: solvable 0.423 unique 0.000 median 28326 capped 8 | no U-turns at all: solvable 0.000 unique 0.000 median 0 capped 0 | wide rake: solvable 0.177 unique 0.027 median 2 capped 0
k=4 n=300 parity-ok=0.34 | one stroke: solvable 0.200 unique 0.000 median 609.5 capped 0 | no U-turns at all: solvable 0.000 unique 0.000 median 0 capped 0 | wide rake: solvable 0.027 unique 0.003 median 3 capped 0
k=6 n=300 parity-ok=0.31 | one stroke: solvable 0.080 unique 0.000 median 95.5 capped 0 | no U-turns at all: solvable 0.000 unique 0.000 median 0 capped 0 | wide rake: solvable 0.003 unique 0.000 median 4 capped 0
out/test_grader_marks_seed11.log
grader soundness with marks: ok 346 bad 0 (unique 34, solved by grader 34)
out/test_grader_marks_seed7.log
grader soundness with marks: ok 365 bad 0 (unique 45, solved by grader 45)
out/test_marks_seed1.log
small (C vs brute force, streamed sols checked): ok 3000 bad 0 solvable 388
medium (C vs CP-SAT): ok 51 bad 0
out/test_marks_seed2.log
small (C vs brute force, streamed sols checked): ok 3000 bad 0 solvable 440
medium (C vs CP-SAT): ok 57 bad 0
out/test_side_pivot_seed1.log
small (C vs brute force): ok 1093 bad 0 solvable 127 (side gate 46, finished sand 50)
medium (C vs CP-SAT): ok 60 bad 0 (counts and histograms compared on 60)
out/test_side_pivot_seed2.log
small (C vs brute force): ok 2099 bad 0 solvable 284 (side gate 86, finished sand 99)
medium (C vs CP-SAT): ok 60 bad 0 (counts and histograms compared on 60)
out/campaign.jsonout/campaign_cache/pairs.pklout/campaign_cache/teaching.pklout/campaign_cache/w1-55.pklout/campaign_cache/w1-56.pklout/campaign_cache/w1-66.pklout/campaign_cache/w1-67.pklout/campaign_cache/w1-77.pklout/campaign_cache/w1-78.pklout/campaign_cache/w2-66.pklout/campaign_cache/w2-67.pklout/campaign_cache/w2-77.pklout/campaign_cache/w2-77x2.pklout/campaign_cache/w2-78.pklout/campaign_cache/w2-88.pklout/campaign_cache/w3-67.pklout/campaign_cache/w3-77.pklout/campaign_cache/w3-77br.pklout/campaign_cache/w3-78.pklout/campaign_cache/w3-88.pklout/campaign_cache/w4-g66.pklout/campaign_cache/w4-g77.pklout/campaign_cache/w4-g78.pklout/campaign_cache/w4-gr.pklout/campaign_cache/w4-p66.pklout/campaign_cache/w4-p77.pklout/campaign_cache/w4-p78.pklout/campaign_cache/w4-pb.pklout/campaign_cache/w4-ring.pklout/campaign_cache/w5-55.pklout/campaign_cache/w5-56.pklout/campaign_cache/w5-66.pklout/campaign_cache/w5-67.pklout/campaign_cache/w5-77.pklout/campaign_cache/w5-78.pklout/campaign_cache/w5-88.pklout/campaign_cache/w5-wb.pklout/campaign_cache/w5-wr.pklout/campaign_cache/w6-67.pklout/campaign_cache/w6-77.pklout/campaign_cache/w6-78.pklout/campaign_cache/w6-88.pklout/campaign_cache/w6-p77.pklout/campaign_cache/w7-p88.pklout/campaign_cache/w7-p99.pklout/campaign_cache/w7-prb.pklout/campaign_cache/w7-ring.pklout/campaign_cache/w7-swr.pklout/campaign_cache/w7-w99.pklout/campaign_cache/w7-wb.pklout/campaign_cache/w7-wr.pklout/campaign_stats.jsonout/continuity.jsonout/daily_cache/d-b67.pklout/daily_cache/d-b77.pklout/daily_cache/d-b78.pklout/daily_cache/d-br77.pklout/daily_cache/d-f66.pklout/daily_cache/d-f66b.pklout/daily_cache/d-f67.pklout/daily_cache/d-f77.pklout/daily_cache/d-f78.pklout/daily_cache/d-g66.pklout/daily_cache/d-g77.pklout/daily_cache/d-g78.pklout/daily_cache/d-gr77.pklout/daily_cache/d-p66.pklout/daily_cache/d-p77.pklout/daily_cache/d-p78.pklout/daily_cache/d-pb77.pklout/daily_cache/d-r66.pklout/daily_cache/d-r67.pklout/daily_cache/d-r67b.pklout/daily_cache/d-r77.pklout/daily_cache/d-r77x2.pklout/daily_cache/d-r78.pklout/daily_cache/d-ring78.pklout/daily_cache/d-s67.pklout/daily_cache/d-s77.pklout/daily_cache/d-s78.pklout/daily_cache/d-s88.pklout/daily_cache/d-sp77.pklout/daily_cache/d-w55.pklout/daily_cache/d-w56.pklout/daily_cache/d-w66.pklout/daily_cache/d-w67.pklout/daily_cache/d-w77.pklout/daily_cache/d-w78.pklout/daily_cache/d-w88.pklout/daily_cache/d-wb77.pklout/daily_cache/d-wr77.pklout/daily_cache/exports.pklout/daily_gardens.jsonout/daily_stats.jsonout/facts.jsonout/facts_pebbles.jsonout/finale2_6x15_1.jsonout/finale_6x15.jsonout/garden_names.jsonout/hint_check.jsonout/hint_stats.jsonout/levels.jsonout/marks_pick.jsonout/marks_vs_grooves.jsonout/pipeline_grooves.jsonout/pipeline_wide.jsonout/proto_gardens.jsonout/sampler_yields.jsonout/search2_two.jsonout/search2_wide.jsonout/search3_wide.jsonout/search4_two.jsonout/table1.jsonout/table2.jsonout/two_pick.jsonout/two_picks.jsonout/wide_pick.json
Figures (shown in the document as SVG; PNG renders for the web) (fig/)
fig/final/2026-10-03-gwern-samon-anatomy.pngfig/final/2026-10-03-gwern-samon-anatomy.svgfig/final/2026-10-03-gwern-samon-calm-agitated.pngfig/final/2026-10-03-gwern-samon-calm-agitated.svgfig/final/2026-10-03-gwern-samon-grooves.pngfig/final/2026-10-03-gwern-samon-grooves.svgfig/final/2026-10-03-gwern-samon-histogram.pngfig/final/2026-10-03-gwern-samon-histogram.svgfig/final/2026-10-03-gwern-samon-ichimatsu.pngfig/final/2026-10-03-gwern-samon-ichimatsu.svgfig/final/2026-10-03-gwern-samon-parity.pngfig/final/2026-10-03-gwern-samon-parity.svgfig/final/2026-10-03-gwern-samon-pebbles.pngfig/final/2026-10-03-gwern-samon-pebbles.svgfig/final/2026-10-03-gwern-samon-ripple.pngfig/final/2026-10-03-gwern-samon-ripple.svgfig/final/2026-10-03-gwern-samon-thumbnail.pngfig/final/2026-10-03-gwern-samon-thumbnail.svgfig/final/2026-10-03-gwern-samon-twogates.pngfig/final/2026-10-03-gwern-samon-twogates.svgfig/final/2026-10-03-gwern-samon-waves.pngfig/final/2026-10-03-gwern-samon-waves.svgfig/final/2026-10-03-gwern-samon-widerake-rule.pngfig/final/2026-10-03-gwern-samon-widerake-rule.svgfig/final/2026-10-03-gwern-samon-widerake.pngfig/final/2026-10-03-gwern-samon-widerake.svg