diff --git a/.gitignore b/.gitignore index dc85509..4340a41 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ src/domain/diagnostics/ *.sln *.sw? Puzzlink_Assistance.js -docs/legacy/* \ No newline at end of file +docs/legacy/* +.plugin/ \ No newline at end of file diff --git a/README.md b/README.md index f312e5f..c5dd4c4 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ The goal is not only to produce a final answer. The app shows each deduction ste - Runs entirely in the browser. - Imports Slitherlink and Masyu puzzles from puzz.link-compatible URLs and Penpa+ URLs. -- Provides an explainable solver workspace with step replay, highlights, rule messages, and live stats. +- Provides an explainable solver workspace with step replay, highlights, rule messages, strong-inference branch inspection, and live stats. - Provides an editor workspace for building Slitherlink and Masyu boards, importing URLs, and loading the result into the solver. - Provides a small Dataset page for curated examples and benchmark-oriented puzzle sets. - Exports supported puzzle states back to puzz.link-style URLs where available. +- Opens canonical puzz.link payloads directly in Solver through shareable deep links. Slitherlink has the more mature rule stack. Masyu is now a first-class puzzle family with editor, import/export, rendering, replay, completion analysis, and an actively evolving solver. @@ -44,9 +45,22 @@ pnpm benchmark:solve ## App Surfaces -- **Solver** (`/`): import a puzzle, run one deduction at a time, jump through the replay timeline, inspect explanations, view stats, and export supported states. +- **Solver** (`/`): import a puzzle, run one deduction at a time, jump through the replay timeline, inspect explanations and strong-inference branches, view stats, and export supported states. - **Editor** (`/editor`): create custom Slitherlink or Masyu grids, edit clues/pearls and marks, import supported URLs, then hand the puzzle to the solver. - **Dataset** (`/dataset`): browse curated public dataset manifests, preview puzzles, and load them into Solver or Editor. +- **Docs** (`/docs`): browse puzzle-family rule documentation and open the explanation for any solver step. + +## Solver Deep Links + +Solver accepts canonical puzz.link-compatible payloads through the `p` query parameter: + +```text +https://smilingwayne.github.io/puzzlekit-web/?p=slither/10/10/... +https://smilingwayne.github.io/puzzlekit-web/?p=mashu/14/8/... +``` + +Deep links support the compact `slither/...` and `mashu/...` payload forms only. Full puzz.link, +pzplus, pzv, and Penpa+ URLs remain supported through the existing manual URL import controls. ## Architecture Snapshot @@ -60,6 +74,7 @@ dataset/ public/ small committed dataset manifests private/ local-only manifests ignored by git docs/ + content/ in-app MDX rule documentation techniques/ puzzle-specific technique notes ``` diff --git a/dataset/public/masyu.json b/dataset/public/masyu.json new file mode 100644 index 0000000..4a17353 --- /dev/null +++ b/dataset/public/masyu.json @@ -0,0 +1,503 @@ +{ + "schemaVersion": 1, + "id": "masyu", + "title": "Masyu Dataset", + "puzzleType": "masyu", + "items": [ + { + "id": "masyu-25x25-0001", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/i0c11020i3030320006009020i9361i9c93i9i03910g50691230000110010a41e3b6aa0d0001010900c90200329940b7930006k009j66901390000109263cj6390009i0i6013003900ma0330220130990013360b7ia009i60001692009171a301399a0902010900ii", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-0002", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/i39001003216230092091006l309079931106ff090000409b9iik01093900010a00i9706i0c001193601ai3a46900b36i0i6a0030j0i003913100ic0a0a0ai633090i011303l099030f23i600630600inc00i0m090f00000b3032i01i11096b029120119i0i009000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-9899866", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/3i09210010909039900i0b20i36a401039300c311a3b00371ic09k231000ic06300ak0a00a300080ai210020a9f213i370031000a0n1000a91003bb000930a03k101c09ic03i02a001000030a111fc910c9i9021010k139f196000003032ai2f0i0919193009i0100", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-4265995", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/j00b0200019i0c079a620306900409900530001c1010c101630304000923596m9a03020i0f3110903a600b98010335022300090i002600393c33l66009800f2130i0300260id0i1103901043l00310050939334009909c01009d3091200l090c20i349k0363000300", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-1088348", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/00i0i00j1901069099l22006000900300300aii609b900006002io69620910i0109c40i4610030i12133a300030l10161000c0430222040220000200f1c6b30500a0i1ga011i063j3601000366i32109039800370i0006904223090a03902099101036b04209i0210", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-6888877", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/00909310ij929900000c10f3c4i91060030cf3097000001904m090a3300b13i020i0900b210ii3a06000991f030000c9496b50309aa30a2011023399d360020i9003601209006i6a009o600609j91963ii1930i000309g03ij03i01990004001363319203016f0o0i", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-5468112", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/ij909631000a133339330003i0j0306a0294000a06090l094007632063ai93k00030100b643402f9103022011034050a49991900005003cdf330l00100000a030j603920i9039150620i40i000920093660202000i1609b6i06161090009091703ij31132i0046000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-4003665", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/2010ij00l43i1001000020i003j03127i30060003l23004600109i102203918330i91600510a3k310c06l0301326000962902110019909031i1i60i9i9000663i030169b0io9c301a20013301001300660k0000960600370060a0i01906003i02103309i00203i0i0", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-2944455", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/00i903990300191400001b0i068279130i019c0009i4001090300il1040a9000dj0100929101cfj1040009332106i410000310360a691216b9009300a0959031a31a00100190i0i5030900i0g00064120b140010610915c4396811000i00i990100i0099i0800609i", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-9566366", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/i0i9f90a010a0a103130101793004110001330c9c0k00180i05i36100400130i304920390220f196c63060000k209393103c1b369193400000900060l60630902i0116jib0b34022003009010016200j0092010066b26399d306000i120009i1i0900i0j000b62000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-587855", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/070606i63939100000911020349c10b0a1012206i0200i002111043606100i10001200631f101106609026000611k04ki220003023011a10c0700i3k9293b0001100110m0k2k00301i9030103i169203i13929600a051001903331131m39011340331010i00921i10", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-6857349", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/0000009903069609ac4906609000i03ii099i3110j010030103022i0c004c9ik004i009000k009i1ia00c9131009214302c3033006b9413909000413600b0300a321221009i02002600i009900209900069609643il315090b0i696100039010b0f01060020960700", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-3231926", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/i13000010099b129060a660d3ii000301005103o098100j03900063331630620110409i113c05b006005061300j11106210i003006k0aa923620660j900000i006i0ic0209k1060if991j060606003090240k020a26a2030900020000096236061000006i310k9000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-4088053", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/0001009a3024117197200610000210a141106900i309j96g3a0i020000009i0c7aa6100j0i031000010903c6b30939a0i1a003960f93260a29960060003i330kbb9001a01302361l30c9i603061300009i303390i0003496i3101000091b6b0090f0093922103060i", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-20x20-6613546", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/20/20/i000006303j0215600c0031070269c30i0009069i200o99213300c4004c9100l000610j96i900366k3991300969a1010c200j0c009i00190000b01606j1006c0904000", + "width": 20, + "height": 20, + "tags": ["hard", "Medium"], + "source": "https://zh.puzzle-masyu.com/?size=12" + }, + { + "id": "masyu-20x20-8268975", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/20/20/09o5020c000a410090a30f433030161091i1070000b00343l03bj3l9c0o20900000920k0756030l036221099060330300110325b11g1303c909e9l09091300ef000960", + "width": 20, + "height": 20, + "tags": ["hard", "Medium"], + "source": "https://zh.puzzle-masyu.com/?size=12" + }, + { + "id": "masyu-25x25-5562527", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/310j2i003301220o939e0094010096201303fk11i39000300016ij00g900031i0iji0in04i33a001m0904206000d3i60399600230b1211300i039201340c39j1i32g06091a004302696712i011500007900603l191100000069ilji0c09290100a0000030i323k690", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-8964356", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/30109306i8c309300100016c1i026i00c60c00069060ai15610200200101i09l0i430aj012000000320i60021003i36600c3108939lc630091930l013310903m0333093c109f60c1i800160j001g10100d4106i1ii00k9001011001090930091010161002i191060i", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-3874630", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/00i090333709c402003010b9000f01010509ci08130i3i300940ji6969i0203i16069009600i90j030m00900di31i9b2020001a0003k06i1l60200000c3j030400003e6316f6c1031c060a0060901900j00k1022009c1a0006o031ica3003600a0a39990010090170", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-6113625", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/1006e10003209011513910ia000f02130f0200a620a30c6002090i9i0210l21010030030fa006f33ii3lc069900061909509110a207034lb6030i33116j00000904303811629d332i0i090031013j0ic00c00989040o6901600333i10199a03036430c14l00i00200", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-7453852", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/010623k3341003009003f3963093090i03339000i0i013010i63c3df3100a20002030904090000i2109ii3096i90060900960820j9a009276633b30090k010039i0904fi0i64f600406i10i400003101036001300l20i90190i142119i90020l900230c0369961000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-6744156", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/61900081000i19010062a3d1040i009039ca000a1110143fi300030100a20a0119900i6l01c0kb915935060150310c330093001l609i90k000fi0b009bc2033000000c9063i3i00k41000032610040960j0di2900396200i919100fi310602000209i210i3000i910", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-8512608", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/0023096020c0a000903404i0b93i1l96i03200660b100010i0000ga300043961649901090000b9108810306090609761109090633601196910660001610i664200a3a0096003391069j0106069003k09604209200f007111301c900100i03a0009a01a0a33i0o00i0", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-879127", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/j20160902001030030330a1632051021006a9e26f00003001310100i0003111213916334133002314i99c6000410116091000005030k010ij1200i333091c011c0c9j4f1i400009030420903m00f9221i0c2090903130o00k0o03200000000a900019f607o1910060", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-5584744", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/4000002j01b905300406139330161k4322093c329k0030020339c707a21100100101700431051b0m035910000230925li4330000110060g0b1370k041133000i0i0i0335b09k6k3b0009913000i6b0000j3903002b00i7ak250i9300300230369i1a093a311102000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-8788377", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/i101013003ic1300300a9090ii900ic9i910o2012010020c002601093f072909c60093202021163b01kf90ii100011003i333o930300000i6k5610009ii02633009a01660i0010003092022c339ca4190300033043afj9c603j30600390060390026000910i001010", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-0003", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/0003090019033936204i01402a1i9i1631009c2099190633a060200006020219f90i0a00i900737020009i00304b3009l0603620a300902f0c0151a0i93c01032803g003300603da03003000003ia0239i06303082033630002f0f263090k6039j0i90b0000039000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-225588", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/0i60030000a0c13a3i00o3011126300il390a220300302313i0ib620163397006006a03103i6039ii93001003416133h29701i000033320ji0590ia003j01000bf0j40i9f0400900l101a013i34i0b1l16900i0030k919900b001001196013973390790c300030000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-78337", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/3110030i0690f9009934946j3003001i3313jk00993c12112a020l10ia033933c060020110900c03il00k0c6i09303040919ff62an3000f30010300j3a3i00j00i0276i0c0a390b11003192e03132093f05i099901690l0a0019295a13a10000130a9790l30i0i0k0", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-1228297", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/100311i01ia2000f32001936096f17ii013010i01504900030706360i690a003209a139c0i00800i303f006bi0300030223bii309030020c99i0a3190a0b903n3610a0a3010343903b203005310o69i50a300i3l0c9310033005c3f2029000001020600303610b2i0", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-4221753", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/000100i0010209096k0a09i006000l6i301a000i600c6930ii010i000352b0ca9160000339090ibi093o0i0009300k0faa733i06i36100i9030620204413i901040i0140630i509e033k00900010c000009001b04al00i0a000a2030ca9003001006833930jj0003i", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-988309", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/3020i23i00060010c3c006000i00e096603c0009093909j010f003304293ki1190000439930619021060900930a0c06190111441030300230k76ia3962290444050a0000c0c0000c13002i19141120106l03900a603i3b70900190i690j002009ci0300c000009600", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-2408667", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/2090903il00l000001909i90c03aik56017001000j010i02000100c900o06i0056023010i000063320g500j3i0600a0023a0199360603001660109000112212kii1000a0030a200j3221301006060c603163i15063jl02106301a6000930j0i930913903391600b00", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-9603489", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/0i0000906iic9001000003kf0041i6203100b506a0099060001j6940kb091091001216000060903k23i00k0003i3d01k39100000000026909o96301f0j1300f6091100423i1039c3006902030k34l3002900000a91060c902000ki0633kai20a0109400i0j0000030", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-5042658", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/109210103009010c39c0910999f6e6l1109i001000001ic23a6if003611260i11m099336a0c911000100a92906266910m96011102109010c91111i109c00111a062139002030923c1606391060696010j13990666i330f3000606a210006009021109613002006000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-0004", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/000k99o9301091009ca0030235019119107b0f0009101704i0ii90200i10b6im0a911a03i11130095631409o4006c17600270a00026000e1140a0fa2301011i006ca309641i0a03137309099362gia0100692004a140096200909l0i0b001ia9c400j3013036c0310", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-25x25-5809037", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/25/25/06016020j9903210090c0000i11020031022j030b130i106070c0360i0109f0200c310061209i000a000i0i603i09810160a0a032090141c210l03393i6070303021320l943010000j001aa9a02ii11f00010920a0l60i9093310jj06f1301001309c30a300010000", + "width": 25, + "height": 25, + "tags": ["hard", "Large"], + "source": "https://zh.puzzle-masyu.com/?size=18" + }, + { + "id": "masyu-15x21-0005", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/21/15/100300030000b000i239009203036040ii006i600131090i0000b0330k2062103360996091020910ik30096002121000010i0a109", + "width": 21, + "height": 15, + "tags": ["easy", "Medium"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0006", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/09a1010a6c09900914690001300106ib00", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0007", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/3k6100000i0mj03381103mi090000313j0", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0008", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/0000c9ia3000cb2000030dk0100c060330", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0009", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/0060jc0000010f90c2099010a6j0000990", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0010", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/00106020b6130000211139000ka3313390", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-25x40-0011", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/40/25/13j0j0133913i0000144001960a3k3i309cc2030000093190i11g3k3j3c30c9920900009ac110i10k6k33a040g99290000331b011i26b6j0430136090000341102030lb6a3a130i1060000193940a2036303300330i103i03i0690609200a00023o030i0021210003160023112191a5200j6i66308000006000006c0i0o80116312006c00i1209j31b0020i6369i000i20i6600000140cj000030j3i9000900039209019c00i0i", + "width": 40, + "height": 25, + "tags": ["easy", "Large"], + "source": "example.txt" + }, + { + "id": "masyu-25x40-0012", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/40/25/13j0j0133913i0000144001960a3k3i300cc2030000093190i1173k3j3930c9920900009ac110i10k6k33a040g99290000331b011i26b6j0430106090000341102030lb6a3a130i1060000193940a2036303300330i103i03i0690609200a00023o030i0021210003160023112191a5200j6i663080000i6000006c0i0i20116312006c02i1209j31b002006369i000i20i6600000140cj000030j3i9000900039209019c00i0i", + "width": 40, + "height": 25, + "tags": ["easy", "Large"], + "source": "example.txt" + }, + { + "id": "masyu-39x49-0013", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090", + "width": 49, + "height": 39, + "tags": ["easy", "Large"], + "source": "example.txt" + }, + { + "id": "masyu-10x18-0014", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/18/10/09i9092000l00a19009009061cc0i0000329111300i200l600b9000c0133", + "width": 18, + "height": 10, + "tags": ["easy", "Medium"], + "source": "example.txt" + }, + { + "id": "masyu-12x14-0015", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/14/12/00011c336369i9m212203333050bbf0036600ig11220333309ii9if0", + "width": 14, + "height": 12, + "tags": ["easy", "Medium"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0016", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/090003ii40340009080c01039960334010", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-10x10-0017", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/10/i300060b0i30003fa000124090099109i0", + "width": 10, + "height": 10, + "tags": ["easy", "Small"], + "source": "example.txt" + }, + { + "id": "masyu-20x29-0018", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/29/20/3026k40306010000c200o0j3i0a00900003a307006a60300009000000126k6k6n6k00000000003i0j0jcj3i310000010060616d6a6000000000326k6k6k6k0040000000209i3i09010091030a61636963i300030002c003026k000490j00031600", + "width": 29, + "height": 20, + "tags": ["easy", "Large"], + "source": "example.txt" + }, + { + "id": "masyu-13x18-0019", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/18/13/1000000a23030000006202k00002i30o601310090010c1120921601309090b0313632039009200", + "width": 18, + "height": 13, + "tags": ["easy", "Medium"], + "source": "example.txt" + }, + { + "id": "masyu-40x40-0020", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/40/40/il0020010160i003ol0c3i00606900010o0203301632010100901206600900i5ef003193cii9000900106990046091191603c6i021201a69k01i23390b963i00i0630i010000o06f00af06106139302002060i0099211a1000i0j0a3ia03321562309i30016023003420000a0k90ii00190200i0fj603j06211i09190f0i9024029710i6230010000000009b0b3096f062j31300a09909060a00i04101920090626b0690020913320101iib31620200859a901b6119a901093a300a0000610310100226210k1931203605000001911030i10m23920a2010cc00200000a63i900l61c6201923i1a9036k090090j6039000a010ji0611921006i020i019ii0i04a90039c102i6000o3390000", + "width": 40, + "height": 40, + "tags": ["easy", "Large"], + "source": "example.txt" + }, + { + "id": "masyu-30x30-0021", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/30/30/i00000300009133i13213a699001c3i903b3k00062633311920i30391320000313a0633621005b003i030601a9333i0i4011903060c39939002i01i0020b19193703o1c006i0109206i00049094aaj390160601090310i31021606109a5020c0i09c9000430903607c0006a901200100000i00a1a1k10263030360c21020i000l0061606c963200bk11300090403006003i000i126ii", + "width": 30, + "height": 30, + "tags": ["easy", "Large"], + "source": "example.txt" + }, + { + "id": "masyu-15x10-0022", + "puzzleType": "masyu", + "sourceUrl": "https://puzz.link/p?mashu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", + "width": 10, + "height": 15, + "tags": ["easy", "Medium"], + "source": "example.txt" + } + ] +} diff --git a/docs/MASYU_AGENT_BRIEF.md b/docs/MASYU_AGENT_BRIEF.md index 1de144a..32864d4 100644 --- a/docs/MASYU_AGENT_BRIEF.md +++ b/docs/MASYU_AGENT_BRIEF.md @@ -10,10 +10,10 @@ Masyu is a first-class puzzle family with: - Penpa+ import. - Solver rendering, replay, explanations, stats, legend/help content, and completion analysis. - Editor support for blank custom grids, URL import, black/white pearl placement, and load-to-solver flow. -- Deterministic rules plus bounded, explainable strong-inference rules. +- Deterministic rules plus bounded strong-inference rules with branch replay and contradiction focus. - Tile-color topology support for inside/outside reasoning. -Public Masyu dataset manifests are not yet committed to `dataset/public`; use private manifests for local experiments until curated public examples are added. +A categorized Masyu manifest is committed to `dataset/public`; use private manifests for additional local experiments. ## Canonical Model @@ -40,7 +40,8 @@ Implemented rule areas include: - Candidate bridge reasoning and pearl candidate pruning. - Adjacent white pearl lookahead. - Tile color propagation, color-line implications, color-pearl implications, and connectivity cut coloring. -- Bounded strong inference for black pearls, white pearls, and empty cells. +- Bounded strong inference for black pearls, confirmed-line component endpoints, + and white pearls. Default expectation: deterministic rules run first, then bounded strong-inference rules reuse the deterministic rule list for local propagation. @@ -59,7 +60,7 @@ Use these files first: - Lookahead helpers: `src/domain/rules/masyu/rules/lookahead*.ts` - Masyu parser/exporter: `src/domain/parsers/puzzlink/masyuPuzzlink.ts`, `src/domain/parsers/penpa/index.ts` - Masyu editor: `src/features/editor/MasyuEditorBoard.tsx`, `src/features/editor/editorStore.ts` -- Tests: `src/domain/rules/masyu/rules.test.ts`, `src/domain/ir/masyu.test.ts`, parser tests +- Tests: `src/domain/rules/masyu/tests/`, `src/domain/rules/masyu/completion.test.ts`, `src/domain/ir/masyu.test.ts`, parser tests Replay and rendering plumbing: @@ -67,6 +68,7 @@ Replay and rendering plumbing: - Diff application: `src/domain/rules/engine.ts` - Solver timeline/checkpoints: `src/features/solver/solverStore.ts` - Board rendering: `src/features/board/CanvasBoard.tsx` +- Strong-inference inspection: `src/features/explanation/BranchInspector.tsx` ## Development Direction @@ -74,7 +76,7 @@ Near-term Masyu work should favor small, maintainable rule improvements over bro - Consolidate repeated line, tile, candidate, and graph primitives when duplication becomes costly. - Prefer named deterministic rules before adding broader assumption search. -- Keep bounded inference explainable through contradiction or common-conclusion messages. +- Keep bounded inference explainable through structured trial traces, contradiction focus, and formal conclusions. - Add focused fixture tests whenever a rule changes. Historical Masyu planning notes live under `docs/legacy/`. They are reference material, not the current source of truth. @@ -90,7 +92,7 @@ Historical Masyu planning notes live under `docs/legacy/`. They are reference ma Useful commands: ```bash -pnpm test:run src/domain/rules/masyu/rules.test.ts +pnpm test:run src/domain/rules/masyu/tests pnpm test:run src/domain/rules/engine.test.ts src/features/solver/solverStore.test.ts pnpm build ``` diff --git a/docs/PROJECT_GUIDE_EN.md b/docs/PROJECT_GUIDE_EN.md index 0232572..f66ebff 100644 --- a/docs/PROJECT_GUIDE_EN.md +++ b/docs/PROJECT_GUIDE_EN.md @@ -34,6 +34,7 @@ dataset/ scripts/ benchmark-solve.ts docs/ + content/ # in-app MDX rule documentation techniques/ # puzzle-specific technique notes legacy/ # old plans and research notes ``` @@ -57,6 +58,11 @@ Boundary rule: This contract is central: solver output must remain deterministic, replay-safe, and explainable. +Bounded inference steps may also store structured branch details: the base +puzzle, initial branch diffs, trial trace, contradiction focus, and formal +conclusion. The shared Branch Inspector replays these details for Slitherlink +and Masyu without changing solver behavior. + ## 4. Plugin Contract Puzzle families are registered in `src/domain/plugins/registry.ts`. @@ -69,6 +75,7 @@ Each `PuzzlePlugin` may provide: - `help` for the puzzle rules popout. - `legend` for board legend examples. - `getStats(puzzle)` for compact board-title stats. +- `liveStats` for puzzle-specific inference coverage series shown in the shared Live Stats panel. - `displayOptions` for puzzle-specific board toggles. Current families: @@ -105,16 +112,17 @@ Implemented: - Editor workspace for Slitherlink and Masyu custom boards, URL import, and load-to-solver flow. - Dataset page with filters, previews, and load-to-Solver/Editor actions. - Plugin-powered rules help, board legend, board display toggles, and compact puzzle stats. +- In-app rule documentation generated from the production rule registry, with stable rule deep links and read-only Canvas examples. - Slitherlink puzz.link-compatible parse/encode, Penpa+ import, deterministic rules, branch-based inference, completion analysis, editor tools, and public dataset example. -- Masyu puzz.link-compatible parse/encode, Penpa+ import, renderer, editor, deterministic rules, completion analysis, tile-color propagation, candidate pruning, bridge reasoning, premature-loop prevention, and bounded strong inference. +- Masyu puzz.link-compatible parse/encode, Penpa+ import, renderer, editor, deterministic rules, completion analysis, tile-color propagation, candidate pruning, bridge reasoning, premature-loop prevention, and inspectable bounded strong inference. - Public/private benchmark manifest workflow. - GitHub Pages release workflow for tagged builds. Partial or planned: -- Public Masyu dataset manifests are not yet committed to `dataset/public`. +- The public dataset includes a categorized Masyu manifest. - Penpa+ support is import-oriented; export completeness is not a current guarantee. -- Puzzle-specific Live Stats wording can still be refined. +- Live Stats uses a shared timeline, step-duration chart, and rule-usage view. Each puzzle plugin declares the meaningful inference coverage series for its own IR primitives. - Difficulty modeling is draft-level. - Nonogram remains planned. diff --git a/docs/content/masyu/overview.mdx b/docs/content/masyu/overview.mdx new file mode 100644 index 0000000..6a48b73 --- /dev/null +++ b/docs/content/masyu/overview.mdx @@ -0,0 +1,12 @@ +## Puzzle rules + +Masyu asks for one continuous loop that passes through every pearl. The loop +turns on black pearls and continues straight in both neighboring cells. It +passes straight through white pearls and turns in at least one neighboring +cell. + +## Solver techniques + +PuzzleKit combines direct pearl rules, local patterns, candidate pruning, +inside/outside tile coloring, connectivity checks, and bounded strong +inference. Each technique below is a real rule registered in the solver. diff --git a/docs/content/masyu/rules/black-pearl-rule.mdx b/docs/content/masyu/rules/black-pearl-rule.mdx new file mode 100644 index 0000000..964c3df --- /dev/null +++ b/docs/content/masyu/rules/black-pearl-rule.mdx @@ -0,0 +1,25 @@ +## What this technique does + +A black pearl must be a turn in the loop. Each of the two exits from the pearl +must continue straight through the next cell. + +## Trigger conditions + +The technique acts when an exit is known, a direction is blocked, or only one +valid turning pair remains. + +## Conclusions + +It can force the required straight extensions, cross out an opposite exit, or +complete the only remaining turn around the pearl. + +## Why it works + +Using opposite exits would pass straight through the black pearl. Turning away +immediately in a neighboring cell would violate the required straight +continuation. + +## Limits and related techniques + +This technique checks direct pearl geometry. Black Pearl Candidate Pruning +combines that geometry with broader local feasibility checks. diff --git a/docs/content/masyu/rules/white-pearl-rule.mdx b/docs/content/masyu/rules/white-pearl-rule.mdx new file mode 100644 index 0000000..8d3745e --- /dev/null +++ b/docs/content/masyu/rules/white-pearl-rule.mdx @@ -0,0 +1,25 @@ +## What this technique does + +A white pearl must be crossed by a straight section of the loop. The loop must +also turn in at least one of the cells immediately before or after the pearl. + +## Trigger conditions + +The technique acts when a known line, blocked direction, or impossible adjacent +turn leaves only one valid straight axis through the pearl. + +## Conclusions + +It can force the opposite line through the pearl, cross out perpendicular exits, +or reject a straight continuation that would make the required nearby turn +impossible. + +## Why it works + +Any alternative would either turn on the white pearl or continue straight on +both adjacent cells. Both outcomes violate the puzzle's white-pearl rule. + +## Limits and related techniques + +This is local deterministic reasoning. Candidate pruning and strong inference +can resolve cases where more than one locally valid axis remains. diff --git a/docs/content/slitherlink/overview.mdx b/docs/content/slitherlink/overview.mdx new file mode 100644 index 0000000..ecc0088 --- /dev/null +++ b/docs/content/slitherlink/overview.mdx @@ -0,0 +1,12 @@ +## Puzzle rules + +Slitherlink asks for one continuous loop along grid edges. A numbered cell +states how many of its four surrounding edges belong to the loop. The loop +cannot branch, cross itself, or split into multiple loops. + +## Solver techniques + +PuzzleKit combines clue and vertex completion, local patterns, sector +constraints, inside/outside coloring, connectivity checks, and bounded +inference. Each technique below is linked by the same stable ID used in the +solver trace. diff --git a/docs/content/slitherlink/rules/vertex-degree.mdx b/docs/content/slitherlink/rules/vertex-degree.mdx new file mode 100644 index 0000000..249718e --- /dev/null +++ b/docs/content/slitherlink/rules/vertex-degree.mdx @@ -0,0 +1,25 @@ +## What this technique does + +Every Slitherlink vertex belongs to either zero or two loop edges. A vertex may +never become a dead end or a branch. + +## Trigger conditions + +The technique acts when the decided edges around a vertex determine the +remaining unknown edges. + +## Conclusions + +If two lines already meet at a vertex, every other incident edge is crossed out. +If one line and only one unknown edge remain, that unknown edge is forced. +If no lines and only one unknown edge remain, it is crossed out. + +## Why it works + +A single used edge creates a dead end, while three or more used edges create a +branch. Neither can be part of one continuous loop. + +## Limits and related techniques + +This rule enforces local degree only. Prevent Premature Loop and connectivity +rules handle larger loop structure. diff --git a/docs/techniques/masyu.md b/docs/techniques/masyu.md index 3b76969..8db2bf5 100644 --- a/docs/techniques/masyu.md +++ b/docs/techniques/masyu.md @@ -365,6 +365,14 @@ contradictions include: - tile-color parity conflicts; - assumptions that conflict with already decided lines. +When a strong-inference step is selected in the reasoning panel, its Branch +Inspector replays the rejected assumption from the base puzzle through each +deterministic trial step to the contradiction. The inspector also provides a +separate **Forced conclusion** tab showing the real deduction produced by the +proof by contradiction. This conclusion tab is visual only: the solver does not +run an additional trial branch, so existing inference budgets and behavior stay +unchanged. + ### Black Pearl Strong Inference For a black pearl, the solver assumes one possible exit direction together with @@ -375,6 +383,22 @@ crossed out in the real puzzle. This rule determines crossed-out line segments. +### Line Component Endpoint Strong Inference + +For each open component of confirmed lines, the solver examines its two +degree-1 endpoints. Endpoints with fewer remaining directions are tested first, +followed by endpoints belonging to longer components. + +For one candidate direction, the solver assumes that the component continues in +that direction and that the endpoint's other unknown exits are crossed out. If +deterministic propagation reaches a contradiction, that candidate direction is +crossed out in the real puzzle. Pearl cells may be component endpoints, but the +dedicated black-pearl strong-inference rule runs first. + +Because component endpoints are already high-value candidates, this rule uses a +tighter default candidate and elapsed-time cap than the broader strong-inference +rules. + ### White Pearl Strong Inference For a white pearl with exactly two feasible straight-axis candidates, the solver @@ -385,16 +409,3 @@ forced in the real puzzle. This rule determines the confirmed lines and crossed-out lines belonging to the forced white-pearl axis. - -### Empty Cell Strong Inference - -For a non-pearl cell, the solver can test bounded degree choices when the local -state has either one known line with exactly two unknown exits, or no known lines -with exactly two unknown exits. - -In the degree-1 case, it assumes each possible continuation in turn. If one -continuation reaches a contradiction, the other continuation is forced. - -In the degree-0 case, it tests the two local modes: both exits are used, or both -exits are crossed out. If one mode reaches a contradiction, the other mode is -forced in the real puzzle. diff --git a/index.html b/index.html index 5e3836a..4c568b2 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - web + PuzzKit Web
diff --git a/package.json b/package.json index c2c845e..9e5711a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "prepare": "husky" }, "dependencies": { + "@mdx-js/react": "^3.1.1", "fflate": "^0.8.2", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -25,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@mdx-js/rollup": "^3.1.1", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19d6246..56e89a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@mdx-js/react': + specifier: ^3.1.1 + version: 3.1.1(@types/react@19.2.14)(react@19.2.6) fflate: specifier: ^0.8.2 version: 0.8.2 @@ -30,6 +33,9 @@ importers: '@eslint/js': specifier: ^9.39.4 version: 9.39.4 + '@mdx-js/rollup': + specifier: ^3.1.1 + version: 3.1.1(rollup@4.61.1) '@playwright/test': specifier: ^1.59.1 version: 1.60.0 @@ -482,6 +488,20 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + + '@mdx-js/rollup@3.1.1': + resolution: {integrity: sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw==} + peerDependencies: + rollup: '>=2' + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -597,6 +617,153 @@ packages: '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -638,15 +805,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} @@ -658,6 +843,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.59.3': resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -717,6 +908,9 @@ packages: resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -810,6 +1004,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -817,6 +1015,9 @@ packages: peerDependencies: postcss: ^8.1.0 + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -851,6 +1052,9 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -859,6 +1063,18 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -867,6 +1083,9 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -877,6 +1096,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -921,6 +1143,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -932,6 +1157,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -955,6 +1183,12 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1021,6 +1255,27 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1035,6 +1290,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1111,6 +1369,15 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -1146,6 +1413,18 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1158,6 +1437,13 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1300,6 +1586,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -1314,9 +1603,124 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1369,6 +1773,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -1420,6 +1827,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1453,10 +1863,36 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1480,6 +1916,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -1526,6 +1967,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1544,6 +1992,9 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -1556,6 +2007,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1596,6 +2053,12 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1633,6 +2096,27 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1642,6 +2126,12 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.0.12: resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1806,6 +2296,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -2137,6 +2630,52 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.14 + acorn: 8.16.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.16.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@types/mdx': 2.0.14 + '@types/react': 19.2.14 + react: 19.2.6 + + '@mdx-js/rollup@3.1.1(rollup@4.61.1)': + dependencies: + '@mdx-js/mdx': 3.1.1 + '@rollup/pluginutils': 5.4.0(rollup@4.61.1) + rollup: 4.61.1 + source-map: 0.7.6 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2203,6 +2742,89 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rollup/pluginutils@5.4.0(rollup@4.61.1)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.61.1 + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + '@standard-schema/spec@1.1.0': {} '@testing-library/dom@10.4.1': @@ -2251,12 +2873,32 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + + '@types/ms@2.1.0': {} + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -2269,6 +2911,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2360,6 +3006,8 @@ snapshots: '@typescript-eslint/types': 8.59.3 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.1': {} + '@vitejs/plugin-react@6.0.1(vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -2445,6 +3093,8 @@ snapshots: assertion-error@2.0.1: {} + astring@1.9.0: {} + autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -2454,6 +3104,8 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 + bail@2.0.2: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2485,6 +3137,8 @@ snapshots: caniuse-lite@1.0.30001792: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -2492,6 +3146,14 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -2501,6 +3163,8 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2509,6 +3173,8 @@ snapshots: colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} + commander@14.0.3: {} concat-map@0.0.1: {} @@ -2545,12 +3211,20 @@ snapshots: decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} dequal@2.0.3: {} detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -2565,6 +3239,20 @@ snapshots: es-module-lexer@2.1.0: {} + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.16.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2679,6 +3367,37 @@ snapshots: estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2689,6 +3408,8 @@ snapshots: expect-type@1.3.0: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2743,6 +3464,51 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -2770,6 +3536,17 @@ snapshots: indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@5.1.0: @@ -2780,6 +3557,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} isexe@2.0.0: {} @@ -2916,6 +3697,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + longest-streak@3.1.0: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -2928,8 +3711,315 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-extensions@2.0.0: {} + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + mimic-function@5.0.1: {} min-indent@1.0.1: {} @@ -2977,6 +4067,16 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -3017,6 +4117,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + property-information@7.2.0: {} + punycode@2.3.1: {} react-dom@19.2.6(react@19.2.6): @@ -3042,11 +4144,72 @@ snapshots: react@19.2.6: {} + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -3081,6 +4244,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0 '@rolldown/binding-win32-x64-msvc': 1.0.0 + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -3115,6 +4309,10 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} std-env@4.1.0: {} @@ -3132,6 +4330,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -3142,6 +4345,14 @@ snapshots: strip-json-comments@3.1.1: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3175,6 +4386,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -3210,6 +4425,43 @@ snapshots: undici@7.25.0: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -3220,6 +4472,16 @@ snapshots: dependencies: punycode: 2.3.1 + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@8.0.12(@types/node@24.12.4)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -3315,3 +4577,5 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 react: 19.2.6 + + zwitch@2.0.4: {} diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..f3d551c 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,12 @@ - \ No newline at end of file + + PuzzleKit Web + + + + diff --git a/src/App.tsx b/src/App.tsx index 6b41264..1e4720f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { DatasetPage } from './app/DatasetPage' +import { DocsPage } from './app/DocsPage' import { EditorPage } from './app/EditorPage' import { WorkspacePage } from './app/WorkspacePage' @@ -8,6 +9,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/src/app/AppDeepLink.test.tsx b/src/app/AppDeepLink.test.tsx new file mode 100644 index 0000000..59a06ba --- /dev/null +++ b/src/app/AppDeepLink.test.tsx @@ -0,0 +1,91 @@ +import { StrictMode } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import App from '../App' +import { createMasyuPuzzle } from '../domain/ir/masyu' +import { + DEFAULT_SLITHERLINK_SAMPLE_URL, + useSolverStore, +} from '../features/solver/solverStore' + +const SLITHER_PAYLOAD = + 'slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' +const MASYU_PAYLOAD = 'mashu/14/8/330000096960006ik00039a00010j0i0000220' + +const renderApp = (entry: string, strict = false) => { + const app = ( + + + + ) + return render(strict ? {app} : app) +} + +describe('PuzzleKit deep links', () => { + beforeEach(() => { + useSolverStore.getState().loadDefaultPuzzle() + }) + + afterEach(() => { + cleanup() + }) + + it('does not change the current solver puzzle when p is absent', async () => { + const puzzle = createMasyuPuzzle(4, 5) + useSolverStore.getState().loadPuzzle(puzzle, { pluginId: 'masyu', sourceUrl: 'manual' }) + + renderApp('/') + + await waitFor(() => expect(useSolverStore.getState().pluginId).toBe('masyu')) + expect(useSolverStore.getState().currentPuzzle.rows).toBe(4) + expect(useSolverStore.getState().currentPuzzle.cols).toBe(5) + expect(useSolverStore.getState().sourceUrl).toBe('manual') + }) + + it.each([ + [SLITHER_PAYLOAD, 'slitherlink', 10, 10], + [MASYU_PAYLOAD, 'masyu', 8, 14], + ])('loads canonical payload %s into the solver', async (payload, pluginId, rows, cols) => { + renderApp(`/?p=${encodeURIComponent(payload)}`, true) + + await waitFor(() => expect(useSolverStore.getState().pluginId).toBe(pluginId)) + const state = useSolverStore.getState() + expect(state.currentPuzzle.rows).toBe(rows) + expect(state.currentPuzzle.cols).toBe(cols) + expect(state.sourceUrl).toBe(`https://puzz.link/p?${payload}`) + expect(state.steps).toEqual([]) + expect(state.pointer).toBe(0) + expect(screen.getByDisplayValue(`https://puzz.link/p?${payload}`)).toBeInTheDocument() + }) + + it('falls back to the default puzzle, reports the error, and still permits manual import', async () => { + renderApp('/?p=slither%2F10%2F10%2Fdsew%3F') + + const dialog = await screen.findByRole('alertdialog', { name: /import failed/i }) + expect(dialog).toBeInTheDocument() + expect(useSolverStore.getState().pluginId).toBe('slitherlink') + expect(useSolverStore.getState().sourceUrl).toBe(DEFAULT_SLITHERLINK_SAMPLE_URL) + expect(screen.getByText(/default puzzle has been loaded instead/i)).not.toBeVisible() + + fireEvent.click(screen.getByRole('button', { name: /close/i })) + const input = screen.getByPlaceholderText(/paste puzz\.link/i) + fireEvent.change(input, { target: { value: 'https://puzz.link/p?slither/3/3/g0h' } }) + fireEvent.click(screen.getByRole('button', { name: /import url/i })) + + await waitFor(() => expect(useSolverStore.getState().currentPuzzle.rows).toBe(3)) + expect(useSolverStore.getState().sourceUrl).toBe('https://puzz.link/p?slither/3/3/g0h') + expect(screen.queryByRole('alertdialog', { name: /import failed/i })).not.toBeInTheDocument() + }) + + it('ignores deep-link parameters outside the solver route', async () => { + const puzzle = createMasyuPuzzle(4, 5) + useSolverStore.getState().loadPuzzle(puzzle, { pluginId: 'masyu', sourceUrl: 'manual' }) + + renderApp(`/editor?p=${encodeURIComponent(SLITHER_PAYLOAD)}`) + + await waitFor(() => expect(screen.getByRole('heading', { name: /puzzlekit editor/i })).toBeInTheDocument()) + expect(useSolverStore.getState().pluginId).toBe('masyu') + expect(useSolverStore.getState().sourceUrl).toBe('manual') + }) +}) diff --git a/src/app/DatasetPage.test.tsx b/src/app/DatasetPage.test.tsx index 03a0ea4..afdf75d 100644 --- a/src/app/DatasetPage.test.tsx +++ b/src/app/DatasetPage.test.tsx @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import App from '../App' import { createSlitherPuzzle } from '../domain/ir/slither' @@ -7,6 +13,8 @@ import { useEditorStore } from '../features/editor/editorStore' import { useSolverStore } from '../features/solver/solverStore' const SAMPLE_URL = 'https://puzz.link/p?slither/3/3/g0h' +const MASYU_20_URL = + 'https://puzz.link/p?mashu/20/20/i000006303j0215600c0031070269c30i0009069i200o99213300c4004c9100l000610j96i900366k3991300969a1010c200j0c009i00190000b01606j1006c0904000' const renderDataset = () => render( @@ -35,58 +43,166 @@ describe('DatasetPage', () => { it('renders dataset navigation, controls, and public manifest cards', () => { renderDataset() - const nav = screen.getByRole('navigation', { name: /workspace navigation/i }) - expect(screen.getByRole('heading', { name: /puzzlekit dataset/i })).toBeInTheDocument() - expect(within(nav).getByRole('link', { name: /dataset/i })).toHaveAttribute('aria-current', 'page') - expect(within(nav).getByRole('link', { name: /solver/i })).toHaveAttribute('href', '/') - expect(within(nav).getByRole('link', { name: /editor/i })).toHaveAttribute('href', '/editor') - expect(within(nav).getByRole('link', { name: /open puzzlekit web on github/i })).toHaveAttribute( - 'href', - 'https://github.com/SmilingWayne/puzzlekit-web', - ) - expect(within(nav).getByRole('link', { name: /open puzzlekit web on github/i })).toHaveAttribute( - 'target', - '_blank', + const nav = screen.getByRole('navigation', { + name: /workspace navigation/i, + }) + expect( + screen.getByRole('heading', { name: /puzzlekit dataset/i }), + ).toBeInTheDocument() + expect(within(nav).getByRole('link', { name: /dataset/i })).toHaveAttribute( + 'aria-current', + 'page', ) - expect(within(nav).getByRole('link', { name: /open puzzlekit web on github/i })).toHaveAttribute( - 'rel', - 'noreferrer', + expect(within(nav).getByRole('link', { name: /solver/i })).toHaveAttribute( + 'href', + '/', ) - expect(screen.getByRole('heading', { name: /dataset controls/i })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'slitherlink-10x10-0001' })).toBeInTheDocument() - expect(screen.getByLabelText(/slitherlink-10x10-0001 dataset preview/i)).toHaveClass( - 'dataset-preview-canvas', + expect(within(nav).getByRole('link', { name: /editor/i })).toHaveAttribute( + 'href', + '/editor', ) - expect(screen.getByText(/showing 56 \/ 56 puzzles/i)).toBeInTheDocument() + expect( + within(nav).getByRole('link', { name: /open puzzlekit web on github/i }), + ).toHaveAttribute('href', 'https://github.com/SmilingWayne/puzzlekit-web') + expect( + within(nav).getByRole('link', { name: /open puzzlekit web on github/i }), + ).toHaveAttribute('target', '_blank') + expect( + within(nav).getByRole('link', { name: /open puzzlekit web on github/i }), + ).toHaveAttribute('rel', 'noreferrer') + expect( + screen.getByRole('heading', { name: /dataset controls/i }), + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'slitherlink-10x10-0001' }), + ).toBeInTheDocument() + expect( + screen.getByLabelText(/slitherlink-10x10-0001 dataset preview/i), + ).toHaveClass('dataset-preview-canvas') + expect(screen.getByText(/showing 56 \/ 111 puzzles/i)).toBeInTheDocument() }) it('filters by search text, size, and tag', () => { renderDataset() - fireEvent.change(screen.getByPlaceholderText(/name, tag, size, type, or url/i), { - target: { value: 'slitherlink-6x6-0001' }, - }) + fireEvent.change( + screen.getByPlaceholderText(/name, tag, size, type, or url/i), + { + target: { value: 'slitherlink-6x6-0001' }, + }, + ) - expect(screen.getByRole('heading', { name: 'slitherlink-6x6-0001' })).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'slitherlink-10x10-0001' })).not.toBeInTheDocument() - expect(screen.getByText(/showing 1 \/ 56 puzzles/i)).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'slitherlink-6x6-0001' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'slitherlink-10x10-0001' }), + ).not.toBeInTheDocument() + expect(screen.getByText(/showing 1 \/ 111 puzzles/i)).toBeInTheDocument() - fireEvent.change(screen.getByPlaceholderText(/name, tag, size, type, or url/i), { - target: { value: '' }, + fireEvent.change( + screen.getByPlaceholderText(/name, tag, size, type, or url/i), + { + target: { value: '' }, + }, + ) + fireEvent.change(screen.getByLabelText(/^size$/i), { + target: { value: '6 x 6' }, }) - fireEvent.change(screen.getByLabelText(/^size$/i), { target: { value: '6 x 6' } }) - expect(screen.getByRole('heading', { name: 'slitherlink-6x6-0001' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'slitherlink-6x6-0002' })).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'slitherlink-10x10-0001' })).not.toBeInTheDocument() - expect(screen.getByText(/showing 2 \/ 56 puzzles/i)).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'slitherlink-6x6-0001' }), + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'slitherlink-6x6-0002' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'slitherlink-10x10-0001' }), + ).not.toBeInTheDocument() + expect(screen.getByText(/showing 2 \/ 111 puzzles/i)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /auto-imported/i })) - expect(screen.getByRole('button', { name: /auto-imported/i })).toHaveAttribute( + expect( + screen.getByRole('button', { name: /auto-imported/i }), + ).toHaveAttribute('data-active', 'true') + expect(screen.getByText(/showing 2 \/ 111 puzzles/i)).toBeInTheDocument() + }) + + it('enables Masyu datasets and resets type-specific filters', () => { + renderDataset() + + const typeSelect = screen.getByDisplayValue('Slitherlink') + expect(screen.getByRole('option', { name: 'Masyu' })).toBeEnabled() + expect( + screen.getByRole('option', { name: /nonogram \(planned\)/i }), + ).toBeDisabled() + + fireEvent.change(screen.getByLabelText(/^size$/i), { + target: { value: '6 x 6' }, + }) + fireEvent.click(screen.getByRole('button', { name: /auto-imported/i })) + fireEvent.change(typeSelect, { target: { value: 'masyu' } }) + + expect(screen.getByText(/showing 55 \/ 111 puzzles/i)).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'masyu-25x25-0001' }), + ).toBeInTheDocument() + expect( + screen.getByLabelText(/masyu-25x25-0001 dataset preview/i), + ).toHaveClass('dataset-preview-canvas') + expect(screen.getByLabelText(/^size$/i)).toHaveValue('all') + expect( + screen.queryByRole('button', { name: /auto-imported/i }), + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /hard/i })).toHaveAttribute( + 'data-active', + 'false', + ) + + fireEvent.change(screen.getByLabelText(/^size$/i), { + target: { value: '20 x 20' }, + }) + expect(screen.getByText(/showing 2 \/ 111 puzzles/i)).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'masyu-20x20-6613546' }), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /hard/i })) + expect(screen.getByRole('button', { name: /hard/i })).toHaveAttribute( 'data-active', 'true', ) - expect(screen.getByText(/showing 2 \/ 56 puzzles/i)).toBeInTheDocument() + + fireEvent.change( + screen.getByPlaceholderText(/name, tag, size, type, or url/i), + { + target: { value: 'masyu-20x20-6613546' }, + }, + ) + expect(screen.getByText(/showing 1 \/ 111 puzzles/i)).toBeInTheDocument() + }) + + it('filters Masyu datasets by difficulty and area tags', () => { + renderDataset() + + fireEvent.change(screen.getByDisplayValue('Slitherlink'), { + target: { value: 'masyu' }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'easy' })) + expect(screen.getByText(/showing 18 \/ 111 puzzles/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Small' })) + expect(screen.getByText(/showing 7 \/ 111 puzzles/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Medium' })) + expect(screen.getByText(/showing 7 \/ 111 puzzles/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Large' })) + expect(screen.getByText(/showing 41 \/ 111 puzzles/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'hard' })) + expect(screen.getByText(/showing 37 \/ 111 puzzles/i)).toBeInTheDocument() }) it('renders compact action links for each dataset puzzle', () => { @@ -101,16 +217,28 @@ describe('DatasetPage', () => { ) expect(sourceLink).toHaveAttribute('target', '_blank') expect(sourceLink).toHaveAttribute('rel', 'noreferrer') - expect(within(card).getByRole('link', { name: 'Solver' })).toHaveAttribute('href', '/') - expect(within(card).getByRole('link', { name: 'Editor' })).toHaveAttribute('href', '/editor') + expect(within(card).getByRole('link', { name: 'Solver' })).toHaveAttribute( + 'href', + '/', + ) + expect(within(card).getByRole('link', { name: 'Editor' })).toHaveAttribute( + 'href', + '/editor', + ) }) it('loads a dataset puzzle into the solver', () => { renderDataset() - fireEvent.click(within(getCard('slitherlink-6x6-0001')).getByRole('link', { name: 'Solver' })) + fireEvent.click( + within(getCard('slitherlink-6x6-0001')).getByRole('link', { + name: 'Solver', + }), + ) - expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: /puzzlekit web/i }), + ).toBeInTheDocument() expect(useSolverStore.getState().pluginId).toBe('slitherlink') expect(useSolverStore.getState().initialPuzzle.rows).toBe(6) expect(useSolverStore.getState().initialPuzzle.cols).toBe(6) @@ -123,9 +251,15 @@ describe('DatasetPage', () => { it('loads a dataset puzzle into the editor', () => { renderDataset() - fireEvent.click(within(getCard('slitherlink-10x10-0001')).getByRole('link', { name: 'Editor' })) + fireEvent.click( + within(getCard('slitherlink-10x10-0001')).getByRole('link', { + name: 'Editor', + }), + ) - expect(screen.getByRole('heading', { name: /puzzlekit editor/i })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: /puzzlekit editor/i }), + ).toBeInTheDocument() expect(useEditorStore.getState().pluginId).toBe('slitherlink') expect(useEditorStore.getState().puzzle.rows).toBe(10) expect(useEditorStore.getState().puzzle.cols).toBe(10) @@ -133,4 +267,34 @@ describe('DatasetPage', () => { 'https://puzz.link/p?slither/10/10/372d23djdh738adl72882dj18538ald838dhaj21d272c', ) }) + + it('loads a Masyu dataset puzzle into the solver and editor', () => { + renderDataset() + + fireEvent.change(screen.getByDisplayValue('Slitherlink'), { + target: { value: 'masyu' }, + }) + const card = getCard('masyu-20x20-6613546') + fireEvent.click(within(card).getByRole('link', { name: 'Solver' })) + + expect(useSolverStore.getState().pluginId).toBe('masyu') + expect(useSolverStore.getState().initialPuzzle.rows).toBe(20) + expect(useSolverStore.getState().initialPuzzle.cols).toBe(20) + expect(useSolverStore.getState().sourceUrl).toBe(MASYU_20_URL) + + renderDataset() + fireEvent.change(screen.getByDisplayValue('Slitherlink'), { + target: { value: 'masyu' }, + }) + fireEvent.click( + within(getCard('masyu-20x20-6613546')).getByRole('link', { + name: 'Editor', + }), + ) + + expect(useEditorStore.getState().pluginId).toBe('masyu') + expect(useEditorStore.getState().puzzle.rows).toBe(20) + expect(useEditorStore.getState().puzzle.cols).toBe(20) + expect(useEditorStore.getState().sourceUrl).toBe(MASYU_20_URL) + }) }) diff --git a/src/app/DatasetPage.tsx b/src/app/DatasetPage.tsx index 66f8a91..eedc1c7 100644 --- a/src/app/DatasetPage.tsx +++ b/src/app/DatasetPage.tsx @@ -21,17 +21,23 @@ type ActivePuzzlePopover = 'rules' | 'legend' | null const DATASET_PREVIEW_SIZE = 136 -const datasetCards: DatasetPuzzleCard[] = publicDatasetManifests.flatMap((manifest) => - manifest.items.map((item) => ({ - ...item, - datasetId: manifest.id, - datasetTitle: manifest.title, - description: `${manifest.title}: ${item.height} x ${item.width} ${item.puzzleType} puzzle.`, - })), +const datasetCards: DatasetPuzzleCard[] = publicDatasetManifests.flatMap( + (manifest) => + manifest.items.map((item) => ({ + ...item, + datasetId: manifest.id, + datasetTitle: manifest.title, + description: `${manifest.title}: ${item.height} x ${item.width} ${item.puzzleType} puzzle.`, + })), ) -const buildSizeLabel = (item: Pick): string => - `${item.height} x ${item.width}` +const publicDatasetPluginIds = new Set( + datasetCards.map((item) => item.puzzleType), +) + +const buildSizeLabel = ( + item: Pick, +): string => `${item.height} x ${item.width}` const parseDatasetPuzzle = (item: BenchmarkDatasetItem) => { const plugin = puzzleRegistry.get(item.puzzleType) @@ -75,16 +81,24 @@ export const DatasetPage = () => { const [query, setQuery] = useState('') const [sizeFilter, setSizeFilter] = useState('all') const [activeTag, setActiveTag] = useState(null) - const [activePuzzlePopover, setActivePuzzlePopover] = useState(null) + const [activePuzzlePopover, setActivePuzzlePopover] = + useState(null) const [actionError, setActionError] = useState('') + const pluginItems = useMemo( + () => datasetCards.filter((item) => item.puzzleType === pluginId), + [pluginId], + ) const tags = useMemo( - () => Array.from(new Set(datasetCards.flatMap((item) => item.tags))).sort(), - [], + () => Array.from(new Set(pluginItems.flatMap((item) => item.tags))).sort(), + [pluginItems], ) const sizeOptions = useMemo( - () => Array.from(new Set(datasetCards.map(buildSizeLabel))).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), - [], + () => + Array.from(new Set(pluginItems.map(buildSizeLabel))).sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ), + [pluginItems], ) const filteredItems = useMemo(() => { const normalizedQuery = query.trim().toLowerCase() @@ -102,7 +116,8 @@ export const DatasetPage = () => { .join(' ') .toLowerCase() const matchesPlugin = item.puzzleType === pluginId - const matchesQuery = normalizedQuery.length === 0 || searchableText.includes(normalizedQuery) + const matchesQuery = + normalizedQuery.length === 0 || searchableText.includes(normalizedQuery) const matchesSize = sizeFilter === 'all' || sizeLabel === sizeFilter const matchesTag = activeTag === null || item.tags.includes(activeTag) return matchesPlugin && matchesQuery && matchesSize && matchesTag @@ -142,10 +157,13 @@ export const DatasetPage = () => {
-
+

Public Dataset

@@ -154,10 +172,15 @@ export const DatasetPage = () => {
- {actionError ?

{actionError}

: null} + {actionError ? ( +

{actionError}

+ ) : null}
{filteredItems.map((item) => ( -
+
@@ -171,7 +194,10 @@ export const DatasetPage = () => { {tag} ))}
-
+
URL @@ -199,7 +225,11 @@ export const DatasetPage = () => { ))}
- {filteredItems.length === 0 ?

No dataset puzzles match the current filters.

: null} + {filteredItems.length === 0 ? ( +

+ No dataset puzzles match the current filters. +

+ ) : null}
@@ -215,19 +245,34 @@ export const DatasetPage = () => { onChange={(event) => { setActivePuzzlePopover(null) setPluginId(event.target.value) + setSizeFilter('all') + setActiveTag(null) }} > - {puzzleRegistry.all().map((plugin) => ( - - ))} + {puzzleRegistry.all().map((plugin) => { + const hasPublicDataset = publicDatasetPluginIds.has( + plugin.id, + ) + return ( + + ) + })} - setActivePuzzlePopover((current) => (current === 'rules' ? null : 'rules')) + setActivePuzzlePopover((current) => + current === 'rules' ? null : 'rules', + ) } onClose={() => setActivePuzzlePopover(null)} /> @@ -235,7 +280,9 @@ export const DatasetPage = () => { pluginId={pluginId} isOpen={activePuzzlePopover === 'legend'} onToggle={() => - setActivePuzzlePopover((current) => (current === 'legend' ? null : 'legend')) + setActivePuzzlePopover((current) => + current === 'legend' ? null : 'legend', + ) } onClose={() => setActivePuzzlePopover(null)} /> @@ -252,7 +299,10 @@ export const DatasetPage = () => {
Tags -
- {tags.map((tag) => ( @@ -272,7 +329,9 @@ export const DatasetPage = () => { key={tag} type="button" data-active={activeTag === tag} - onClick={() => setActiveTag((current) => (current === tag ? null : tag))} + onClick={() => + setActiveTag((current) => (current === tag ? null : tag)) + } > {tag} diff --git a/src/app/DocsPage.test.tsx b/src/app/DocsPage.test.tsx new file mode 100644 index 0000000..b90aac6 --- /dev/null +++ b/src/app/DocsPage.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { DocsPage } from './DocsPage' + +const renderDocs = (path: string) => + render( + + + } /> + + , + ) + +describe('DocsPage', () => { + afterEach(cleanup) + + it('renders the generated puzzle-family documentation index', () => { + renderDocs('/docs') + + expect( + screen.getByRole('heading', { name: 'Rule Documentation' }), + ).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Masyu' })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'Slitherlink' }), + ).toBeInTheDocument() + const nav = screen.getByRole('navigation', { + name: /workspace navigation/i, + }) + expect(within(nav).getByRole('link', { name: 'Docs' })).toHaveAttribute( + 'aria-current', + 'page', + ) + }) + + it('renders a documented rule and its read-only example', () => { + renderDocs('/docs/masyu/rules/white-pearl-rule') + + expect( + screen.getByRole('heading', { name: 'White Pearl Rule', level: 1 }), + ).toBeInTheDocument() + expect(screen.getByText('documented')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Play deduction' }), + ).toBeInTheDocument() + expect( + screen.getByLabelText('Rule documentation example'), + ).toBeInTheDocument() + }) +}) diff --git a/src/app/DocsPage.tsx b/src/app/DocsPage.tsx new file mode 100644 index 0000000..1bc1ce4 --- /dev/null +++ b/src/app/DocsPage.tsx @@ -0,0 +1,42 @@ +import { Navigate, Route, Routes, useParams } from 'react-router-dom' +import { DocsIndex } from '../features/docs/DocsIndex' +import { RuleDocPage } from '../features/docs/RuleDocPage' +import { getRuleDocEntry } from '../features/docs/ruleDocRegistry' +import { WorkspaceHeader } from './WorkspaceHeader' +import './workspace.css' + +const PuzzleDocsRoute = () => { + const { puzzleId } = useParams() + return puzzleId ? ( + + ) : ( + + ) +} + +const RuleDocsRoute = () => { + const { puzzleId, ruleId } = useParams() + const entry = + puzzleId && ruleId ? getRuleDocEntry(puzzleId, ruleId) : undefined + return entry ? : +} + +export const DocsPage = () => ( +
+
+
+ +
+
+ + } /> + } /> + } /> + } /> + +
+) diff --git a/src/app/WorkspaceHeader.tsx b/src/app/WorkspaceHeader.tsx index 20b0b9e..dda522e 100644 --- a/src/app/WorkspaceHeader.tsx +++ b/src/app/WorkspaceHeader.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom' -type WorkspacePageId = 'solver' | 'dataset' | 'editor' +type WorkspacePageId = 'solver' | 'dataset' | 'editor' | 'docs' type WorkspaceHeaderProps = { title: string @@ -14,6 +14,7 @@ const navItems: Array<{ id: WorkspacePageId; label: string; to: string }> = [ { id: 'solver', label: 'Solver', to: '/' }, { id: 'dataset', label: 'Dataset', to: '/dataset' }, { id: 'editor', label: 'Editor', to: '/editor' }, + { id: 'docs', label: 'Docs', to: '/docs' }, ] export const WorkspaceHeader = ({ diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index 942b0fb..588f1db 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -61,6 +61,7 @@ describe('WorkspacePage', () => { renderWorkspace() expect(screen.getByRole('heading', { name: /puzzlekit web/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /editor/i })).toHaveAttribute('href', '/editor') + expect(screen.getByRole('link', { name: /^docs$/i })).toHaveAttribute('href', '/docs') expect(screen.getByRole('link', { name: /open puzzlekit web on github/i })).toHaveAttribute( 'href', 'https://github.com/SmilingWayne/puzzlekit-web', @@ -398,8 +399,8 @@ describe('WorkspacePage', () => { const statsTimeline = screen.getByLabelText(/trace timeline/i) const liveStats = screen.getByLabelText(/live stats/i) expect(statsTimeline).toHaveValue('2') - expect(within(liveStats).getByText(/board progress/i)).toBeInTheDocument() expect(within(liveStats).getByText(/inference coverage/i)).toBeInTheDocument() + expect(within(liveStats).getByText(/cumulative solve time/i)).toBeInTheDocument() fireEvent.change(statsTimeline, { target: { value: '1' } }) @@ -423,6 +424,8 @@ describe('WorkspacePage', () => { affectedSectors: [], timestamp: Date.now(), durationMs: 1, + chainDurationMs: 5, + ruleApplyMs: 2, }, { id: 'step-2', @@ -438,6 +441,8 @@ describe('WorkspacePage', () => { affectedSectors: [], timestamp: Date.now() + 1, durationMs: 1, + chainDurationMs: 8, + ruleApplyMs: 3, }, ] const puzzle = createSlitherPuzzle(1, 2) @@ -460,17 +465,27 @@ describe('WorkspacePage', () => { renderWorkspace() const liveStats = screen.getByLabelText(/live stats/i) - const progressChart = within(liveStats).getByLabelText(/^board progress$/i) const coverageChart = within(liveStats).getByLabelText(/^inference coverage$/i) - expect(within(progressChart).getByText(/progress 28\.6%/i)).toBeInTheDocument() - expect(within(coverageChart).getByText(/edge 28\.6%/i)).toBeInTheDocument() - expect(within(coverageChart).getByText(/cell 50\.0%/i)).toBeInTheDocument() + const durationChart = within(liveStats).getByLabelText(/^cumulative solve time$/i) + expect(within(coverageChart).getByText(/edge decisions 28\.6%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/cell colors 50\.0%/i)).toBeInTheDocument() + expect(within(durationChart).getByText(/full step 13\.0 ms \(\+8\.0 ms this step\)/i)).toBeInTheDocument() + expect(within(durationChart).getByText(/matched rule 5\.0 ms \(\+3\.0 ms this step\)/i)).toBeInTheDocument() + expect(coverageChart.querySelectorAll('path[data-interpolation="linear"]')).toHaveLength(4) + expect(durationChart.querySelectorAll('path[data-interpolation="step-after"]')).toHaveLength(2) + expect(durationChart.querySelector('path[data-interpolation="step-after"]')?.getAttribute('d')?.match(/ L /g)).toHaveLength(4) fireEvent.change(screen.getByLabelText(/trace timeline/i), { target: { value: '1' } }) - expect(within(progressChart).getByText(/progress 14\.3%/i)).toBeInTheDocument() - expect(within(coverageChart).getByText(/edge 14\.3%/i)).toBeInTheDocument() - expect(within(coverageChart).getByText(/cell 0\.0%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/edge decisions 14\.3%/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/cell colors 0\.0%/i)).toBeInTheDocument() + expect(within(durationChart).getByText(/full step 5\.0 ms \(\+5\.0 ms this step\)/i)).toBeInTheDocument() + expect(within(durationChart).getByText(/matched rule 2\.0 ms \(\+2\.0 ms this step\)/i)).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/trace timeline/i), { target: { value: '0' } }) + + expect(within(durationChart).getByText(/full step 0\.0 ms \(\+0\.0 ms this step\)/i)).toBeInTheDocument() + expect(within(durationChart).getByText(/matched rule 0\.0 ms \(\+0\.0 ms this step\)/i)).toBeInTheDocument() }) it('shows the optimized live stats summary and charts', () => { @@ -486,12 +501,53 @@ describe('WorkspacePage', () => { expect(within(liveStats).queryByText(/rule applications/i)).not.toBeInTheDocument() expect(within(liveStats).queryByText(/trace progress/i)).not.toBeInTheDocument() - expect(within(liveStats).getByLabelText(/^board progress$/i)).toBeInTheDocument() + expect(within(liveStats).queryByLabelText(/^board progress$/i)).not.toBeInTheDocument() const coverageChart = within(liveStats).getByLabelText(/^inference coverage$/i) - expect(within(coverageChart).getByText(/edge/i)).toBeInTheDocument() - expect(within(coverageChart).getByText(/cell/i)).toBeInTheDocument() - expect(within(coverageChart).getByText(/vertex/i)).toBeInTheDocument() - expect(within(coverageChart).queryByText(/sector/i)).not.toBeInTheDocument() + expect(within(coverageChart).getByText(/edge decisions/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/cell colors/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/vertex candidates/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/sector constraints/i)).toBeInTheDocument() + expect(within(liveStats).getByLabelText(/^cumulative solve time$/i)).toBeInTheDocument() + }) + + it('shows Masyu-specific live stats coverage', () => { + const puzzle = createMasyuPuzzle(2, 2) + const line = lineKey([0, 0], [0, 1]) + const tile = tileKey(0, 0) + const steps: RuleStep[] = [{ + id: 'step-1', + ruleId: 'masyu-stats', + ruleName: 'Masyu Stats', + message: 'update line and tile', + diffs: [ + { kind: 'line', lineKey: line, from: 'unknown', to: 'line' }, + { kind: 'tile', tileKey: tile, fromFill: null, toFill: 'green' }, + ], + affectedCells: [], + affectedEdges: [], + affectedLines: [line], + affectedTiles: [tile], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 4, + }] + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'masyu', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps, + traceStatsCache: rebuildTraceStatsCache(puzzle, steps), + pointer: 1, + })) + + renderWorkspace() + + const coverageChart = within(screen.getByLabelText(/live stats/i)).getByLabelText(/^inference coverage$/i) + expect(within(coverageChart).getByText(/line decisions/i)).toBeInTheDocument() + expect(within(coverageChart).getByText(/tile colors/i)).toBeInTheDocument() + expect(within(coverageChart).queryByText(/edge decisions/i)).not.toBeInTheDocument() + expect(within(coverageChart).queryByText(/vertex candidates/i)).not.toBeInTheDocument() }) it('keeps future trace rules visible in live stats while browsing an earlier prefix', () => { @@ -794,15 +850,15 @@ describe('WorkspacePage', () => { renderWorkspace() expect(screen.getByText(/showing 30 \/ 35/i)).toBeInTheDocument() - expect(screen.getByText(/^35\. test rule$/i)).toBeInTheDocument() - expect(screen.queryByText(/^5\. test rule$/i)).not.toBeInTheDocument() + expect(screen.getAllByRole('link', { name: 'Test Rule' })).toHaveLength(30) + expect(screen.queryByText(/^5\.$/i)).not.toBeInTheDocument() const showAll = screen.getByRole('button', { name: /show all/i }) fireEvent.click(showAll) expect(showAll).toHaveAttribute('aria-pressed', 'true') expect(screen.getByText(/showing 35 \/ 35/i)).toBeInTheDocument() - expect(screen.getByText(/^1\. test rule$/i)).toBeInTheDocument() + expect(screen.getAllByRole('link', { name: 'Test Rule' })).toHaveLength(35) }) it('summarizes Slitherlink edge updates in reasoning steps', () => { @@ -836,6 +892,10 @@ describe('WorkspacePage', () => { renderWorkspace() expect(screen.getByText('edge updates: 1')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Edge Rule' })).toHaveAttribute( + 'href', + '/docs/slitherlink/rules/edge-rule', + ) }) it('summarizes Masyu line updates and line crosses in reasoning steps', () => { @@ -876,6 +936,170 @@ describe('WorkspacePage', () => { expect(screen.getByText('line updates: 1, line crosses: 1')).toBeInTheDocument() }) + it('opens and navigates the Slitherlink strong inference branch inspector', () => { + const url = + 'https://puzz.link/p?slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' + const store = useSolverStore.getState() + store.importFromUrl(url, 'slitherlink') + store.nextStep() + store.nextStep() + store.nextStep() + + renderWorkspace() + + const reasoningPanel = screen.getByRole('heading', { name: /reasoning steps/i }).closest('section') + if (!reasoningPanel) { + throw new Error('Expected reasoning panel') + } + const strongInferenceMessage = within(reasoningPanel).getByText(/Strong inference: edge V\(1, 2\)-V\(1, 3\)/i) + expect(strongInferenceMessage.firstChild).toHaveTextContent('[View details]') + expect(within(reasoningPanel).getAllByRole('button', { name: /view details/i })).toHaveLength(1) + expect(within(reasoningPanel).queryByText(/\[View details\].*Apply Vertex Flow/i)).not.toBeInTheDocument() + fireEvent.click(within(reasoningPanel).getByRole('button', { name: /view details/i })) + + const dialog = screen.getByRole('dialog', { name: /branch inspector/i }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(within(dialog).getByText(/vertex-degree contradiction at V\(5, 3\)/i)).toBeInTheDocument() + expect(within(dialog).getByLabelText('Branch replay step', { exact: true })).toHaveAttribute('max', '10') + const inspectorCanvas = within(dialog).getByLabelText(/slitherlink branch inspector canvas/i) + expect(inspectorCanvas).toBeInTheDocument() + const inspectorZoom = within(dialog).getByLabelText('Branch inspector board zoom', { exact: true }) + expect(inspectorZoom).toHaveValue('100') + expect(inspectorZoom).toHaveAttribute('min', '20') + expect(inspectorZoom).toHaveAttribute('max', '200') + expect(inspectorZoom).toHaveAttribute('step', '5') + expect(inspectorCanvas).toHaveStyle({ width: '616px', height: '616px' }) + const stageDetails = within(dialog).getByLabelText('Current branch replay step', { exact: true }) + expect(within(stageDetails).getByText('Base puzzle', { exact: true })).toBeInTheDocument() + const inspectorFooter = dialog.querySelector('.branch-inspector-controls') + expect(inspectorFooter).not.toBeNull() + expect(inspectorFooter).not.toHaveTextContent(/base puzzle before the inference/i) + + fireEvent.change(inspectorZoom, { target: { value: '20' } }) + expect(inspectorZoom).toHaveValue('20') + expect(within(dialog).getByText('20%', { exact: true })).toBeInTheDocument() + expect(inspectorCanvas).toHaveStyle({ width: '123px', height: '123px' }) + + fireEvent.click(within(dialog).getByRole('button', { name: /next/i })) + expect(within(stageDetails).getByText(/apply the branch assumption/i)).toBeInTheDocument() + expect(inspectorZoom).toHaveValue('20') + + fireEvent.click(within(dialog).getByRole('tab', { name: /branch b unresolved/i })) + expect(within(dialog).getByLabelText('Branch replay step', { exact: true })).toHaveAttribute('max', '3') + expect(inspectorZoom).toHaveValue('20') + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: /branch inspector/i })).not.toBeInTheDocument() + }) + + it('opens and navigates a Masyu strong inference branch inspector', () => { + const puzzle = createMasyuPuzzle(3, 3) + const assumed = lineKey([1, 0], [1, 1]) + const downstream = lineKey([1, 1], [1, 2]) + const forced = lineKey([0, 1], [1, 1]) + const steps: RuleStep[] = [ + { + id: 'step-1', + ruleId: 'masyu-line-component-endpoint-strong-inference', + ruleName: 'Masyu Line Component Endpoint Strong Inference', + message: 'The assumption contradicts the puzzle, so the alternative is forced.', + diffs: [{ kind: 'line', lineKey: forced, from: 'unknown', to: 'line' }], + affectedCells: [cellKey(1, 1)], + affectedEdges: [], + affectedLines: [forced], + affectedSectors: [], + timestamp: Date.now(), + durationMs: 1, + inferenceDetails: { + kind: 'masyu-strong', + conclusion: 'opposite-branch', + basePuzzle: puzzle, + defaultBranchId: 'assumption', + branches: [ + { + id: 'assumption', + label: 'Assume the east continuation', + role: 'trial', + initialDiffs: [ + { kind: 'line', lineKey: assumed, from: 'unknown', to: 'line' }, + ], + status: 'contradiction', + traceSteps: [ + { + ruleId: 'test-downstream', + ruleName: 'Test Downstream', + message: 'Extend the trial line.', + diffs: [ + { kind: 'line', lineKey: downstream, from: 'unknown', to: 'line' }, + ], + affectedCells: [cellKey(1, 1)], + affectedEdges: [], + affectedLines: [downstream], + affectedSectors: [], + }, + ], + contradiction: { + kind: 'cell-degree', + message: 'cell-degree contradiction at C(2, 2)', + cells: [cellKey(1, 1)], + }, + }, + { + id: 'conclusion', + label: 'Forced conclusion', + role: 'forced-conclusion', + initialDiffs: [ + { kind: 'line', lineKey: forced, from: 'unknown', to: 'line' }, + ], + status: 'forced', + traceSteps: [], + }, + ], + }, + }, + ] + useSolverStore.setState((state) => ({ + ...state, + pluginId: 'masyu', + initialPuzzle: puzzle, + currentPuzzle: puzzle, + steps, + traceStatsCache: rebuildTraceStatsCache(puzzle, steps), + pointer: 1, + terminalReport: null, + })) + + renderWorkspace() + const reasoningPanel = screen.getByRole('heading', { name: /reasoning steps/i }).closest('section') + if (!reasoningPanel) { + throw new Error('Expected reasoning panel') + } + fireEvent.click(within(reasoningPanel).getByRole('button', { name: /view details/i })) + + const dialog = screen.getByRole('dialog', { name: /branch inspector/i }) + const inspectorCanvas = within(dialog).getByLabelText(/masyu branch inspector canvas/i) + expect(inspectorCanvas).toBeInTheDocument() + const inspectorZoom = within(dialog).getByLabelText('Branch inspector board zoom', { exact: true }) + expect(inspectorZoom).toHaveValue('100') + expect(within(dialog).getByText(/cell-degree contradiction at C\(2, 2\)/i)).toBeInTheDocument() + expect(within(dialog).getByLabelText('Branch replay step', { exact: true })).toHaveAttribute('max', '2') + + fireEvent.change(inspectorZoom, { target: { value: '200' } }) + expect(inspectorZoom).toHaveValue('200') + expect(within(dialog).getByText('200%', { exact: true })).toBeInTheDocument() + expect(inspectorCanvas).toHaveStyle({ width: '504px', height: '504px' }) + + fireEvent.click(within(dialog).getByRole('button', { name: /next/i })) + fireEvent.click(within(dialog).getByRole('button', { name: /next/i })) + expect(within(dialog).getByText('Test Downstream', { exact: true })).toBeInTheDocument() + expect(inspectorZoom).toHaveValue('200') + + fireEvent.click(within(dialog).getByRole('tab', { name: /forced conclusion forced/i })) + expect(within(dialog).getByText(/forced because the trial assumption contradicts/i)).toBeInTheDocument() + expect(within(dialog).getByLabelText('Branch replay step', { exact: true })).toHaveAttribute('max', '1') + expect(inspectorZoom).toHaveValue('200') + }) + it('keeps replay and puzzle I/O controls in the intended compact order', () => { renderWorkspace() diff --git a/src/app/WorkspacePage.tsx b/src/app/WorkspacePage.tsx index c027148..42c00e5 100644 --- a/src/app/WorkspacePage.tsx +++ b/src/app/WorkspacePage.tsx @@ -1,4 +1,6 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' +import { useLocation } from 'react-router-dom' +import { parsePuzzlinkDeepLink } from '../domain/deepLink/puzzlinkDeepLink' import { CanvasBoard } from '../features/board/CanvasBoard' import { ExplanationPanel } from '../features/explanation/ExplanationPanel' import { ControlPanel } from '../features/solver/ControlPanel' @@ -8,6 +10,8 @@ import { WorkspaceHeader } from './WorkspaceHeader' import './workspace.css' export const WorkspacePage = () => { + const location = useLocation() + const didHandleDeepLink = useRef(false) const { pluginId, currentPuzzle, @@ -24,9 +28,28 @@ export const WorkspacePage = () => { solveProgress, goToStep, isRunning, + loadPuzzle, + loadDefaultPuzzle, } = useSolverStore() const activeSteps = useMemo(() => steps.slice(0, pointer), [steps, pointer]) + useEffect(() => { + if (didHandleDeepLink.current) { + return + } + didHandleDeepLink.current = true + + const result = parsePuzzlinkDeepLink(location.search) + if (result.status === 'valid') { + loadPuzzle(result.puzzle, { + pluginId: result.pluginId, + sourceUrl: result.sourceUrl, + }) + } else if (result.status === 'invalid') { + loadDefaultPuzzle(`${result.message} The default puzzle has been loaded instead.`) + } + }, [loadDefaultPuzzle, loadPuzzle, location.search]) + return (
@@ -48,6 +71,7 @@ export const WorkspacePage = () => { onSetDisplayOption={setDisplayOption} /> {
- +
{solveProgress ? ( diff --git a/src/app/workspace.css b/src/app/workspace.css index 2b4f04c..8380b83 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -65,6 +65,235 @@ background: #f1f5f9; } +.docs-workspace { + display: grid; + align-content: start; + gap: 16px; +} + +.docs-header-grid { + width: 100%; +} + +.docs-index { + display: grid; + gap: 16px; + width: min(1120px, 100%); + margin: 0 auto; +} + +.docs-intro h1, +.docs-family h2, +.docs-family h3, +.docs-article h1, +.docs-article h2, +.docs-article h3 { + color: #0f172a; +} + +.docs-intro h1 { + margin: 0; + font-size: 1.5rem; +} + +.docs-intro p { + margin: 6px 0 0; + color: #475569; +} + +.docs-family-overview { + margin-top: 20px; + border-top: 1px solid #e5e7eb; + padding-top: 4px; +} + +.docs-family { + display: grid; + gap: 18px; +} + +.docs-family > header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 12px; +} + +.docs-family > header h2, +.docs-family > header p { + margin: 0; +} + +.docs-family > header p { + margin-top: 3px; + color: #64748b; + font-size: 0.88rem; +} + +.docs-family > header a, +.docs-breadcrumbs a { + color: #0369a1; +} + +.docs-rule-group { + display: grid; + gap: 8px; +} + +.docs-rule-group h3 { + margin: 0; + font-size: 0.92rem; +} + +.docs-rule-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 8px; +} + +.docs-rule-list a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + color: #1e293b; + padding: 9px 10px; + text-decoration: none; +} + +.docs-rule-list a:hover, +.docs-rule-list a:focus-visible { + border-color: #38bdf8; + background: #f0f9ff; +} + +.docs-rule-list small { + color: #64748b; + font-size: 0.72rem; + text-transform: uppercase; +} + +.docs-article { + width: min(920px, 100%); + margin: 0 auto; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #ffffff; + padding: clamp(16px, 4vw, 40px); +} + +.docs-breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 7px; + color: #64748b; + font-size: 0.84rem; +} + +.docs-article-header { + margin: 24px 0; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 24px; +} + +.docs-article-header h1 { + margin: 6px 0; + font-size: clamp(1.8rem, 4vw, 2.5rem); + line-height: 1.15; +} + +.docs-article-header p { + margin: 4px 0; + color: #475569; +} + +.docs-article-header code { + display: inline-block; + margin-top: 10px; + border-radius: 5px; + background: #f1f5f9; + color: #334155; + padding: 3px 6px; +} + +.docs-status { + display: inline-flex; + border-radius: 999px; + padding: 3px 8px; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.docs-status-documented { + background: #dcfce7; + color: #166534; +} + +.docs-status-draft { + background: #fef3c7; + color: #92400e; +} + +.docs-prose { + color: #334155; + line-height: 1.7; +} + +.docs-prose h2 { + margin: 28px 0 8px; + font-size: 1.25rem; +} + +.docs-prose p { + margin: 8px 0; +} + +.rule-example { + display: grid; + gap: 10px; + margin: 28px 0 0; + border: 1px solid #dbeafe; + border-radius: 10px; + background: #f8fafc; + padding: 12px; +} + +.rule-example-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px; +} + +.rule-example-tabs { + display: flex; + gap: 6px; +} + +.rule-example .surface-board { + display: grid; + gap: 8px; +} + +.rule-example .surface-board-tools { + display: none; +} + +.rule-example .board-scroll-shell { + max-height: 430px; +} + +.rule-example figcaption { + color: #475569; + font-size: 0.88rem; +} + .workspace-grid { display: grid; grid-template-columns: minmax(0, 2.25fr) minmax(300px, 0.9fr); @@ -1141,6 +1370,19 @@ button[data-active='true'] { font-weight: 600; } +.step-rule-link { + color: inherit; + font-weight: 800; + text-decoration-color: #38bdf8; + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} + +.step-rule-link:hover, +.step-rule-link:focus-visible { + color: #0369a1; +} + .step-message { margin: 4px 0 0; color: #4b5563; @@ -1152,6 +1394,261 @@ button[data-active='true'] { font-size: 0.85rem; } +.step-details-link { + appearance: none; + border: 0; + background: transparent; + color: #0284c7; + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; +} + +.step-details-link:hover { + color: #0369a1; +} + +.step-details-link:focus-visible { + border-radius: 3px; + outline: 2px solid #67e8f9; + outline-offset: 2px; +} + +.branch-inspector-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 20px; + background: rgb(15 23 42 / 0.48); +} + +.branch-inspector-dialog { + width: min(1180px, 100%); + max-height: calc(100vh - 40px); + overflow: auto; + border: 1px solid #cbd5e1; + border-radius: 14px; + background: #ffffff; + box-shadow: 0 24px 80px rgb(15 23 42 / 0.28); + padding: 16px; +} + +.branch-inspector-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.branch-inspector-header h2, +.branch-inspector-header p { + margin: 0; +} + +.branch-inspector-header h2 { + color: #0f172a; + font-size: 1.15rem; +} + +.branch-inspector-header p, +.branch-inspector-muted { + margin-top: 4px; + color: #64748b; + font-size: 0.86rem; +} + +.branch-inspector-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 14px 0; +} + +.branch-inspector-tabs button { + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #334155; + padding: 7px 10px; +} + +.branch-inspector-tabs button[data-active='true'] { + border-color: #0891b2; + background: #ecfeff; + color: #0f172a; +} + +.branch-inspector-tabs span { + margin-left: 5px; + color: #64748b; + font-size: 0.76rem; +} + +.branch-inspector-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 250px; + gap: 14px; +} + +.branch-inspector-board, +.branch-inspector-details { + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + padding: 10px; +} + +.branch-inspector-board .board-scroll-shell { + max-height: min(62vh, 720px); + background: #ffffff; +} + +.surface-board-tools { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + +.branch-inspector-status { + display: flex; + justify-content: space-between; + gap: 8px; + color: #64748b; + font-size: 0.85rem; +} + +.branch-inspector-status strong[data-status='contradiction'], +.branch-inspector-contradiction { + color: #b91c1c; +} + +.branch-inspector-status strong[data-status='forced'], +.branch-inspector-forced { + color: #6d28d9; +} + +.branch-inspector-contradiction { + line-height: 1.45; +} + +.branch-inspector-forced { + line-height: 1.45; +} + +.branch-inspector-stage { + height: 150px; + overflow: auto; + margin-top: 18px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + padding: 10px; +} + +.branch-inspector-stage strong { + color: #0f172a; + font-size: 0.9rem; +} + +.branch-inspector-stage p { + margin: 5px 0 0; + color: #64748b; + font-size: 0.84rem; + line-height: 1.45; +} + +.branch-inspector-legend { + display: grid; + gap: 8px; + margin-top: 18px; + color: #475569; + font-size: 0.82rem; +} + +.branch-inspector-legend span { + display: flex; + align-items: center; + gap: 7px; +} + +.branch-inspector-legend i { + width: 14px; + height: 14px; + border: 3px solid; + border-radius: 4px; +} + +.branch-inspector-legend i[data-kind='assumption'] { + border-color: #f97316; +} + +.branch-inspector-legend i[data-kind='conclusion'] { + border-color: #7c3aed; + background: rgb(124 58 237 / 0.16); +} + +.branch-inspector-legend i[data-kind='step'] { + border-color: #22d3ee; +} + +.branch-inspector-legend i[data-kind='contradiction'] { + border-color: #dc2626; + background: rgb(220 38 38 / 0.2); +} + +.branch-inspector-controls { + display: flex; + justify-content: flex-end; + margin-top: 14px; +} + +.branch-inspector-navigation { + display: flex; + align-items: center; + gap: 8px; +} + +.branch-inspector-navigation input { + width: min(240px, 28vw); + accent-color: #0891b2; +} + +.branch-inspector-navigation output { + min-width: 54px; + color: #475569; + font-size: 0.82rem; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 820px) { + .branch-inspector-layout { + grid-template-columns: 1fr; + } + + .branch-inspector-header { + align-items: stretch; + flex-direction: column; + } + + .branch-inspector-navigation { + width: 100%; + justify-content: flex-end; + } + + .surface-board-tools { + justify-content: stretch; + } + + .surface-board-tools .board-zoom-control { + width: 100%; + } +} + .stats-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1320,7 +1817,10 @@ button[data-active='true'] { .rule-usage-bar-row { display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 2fr) minmax(116px, auto); + grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 2fr) minmax( + 116px, + auto + ); gap: 8px; align-items: center; border: 1px solid #e5e7eb; @@ -1560,8 +2060,7 @@ button[data-active='true'] { border-radius: 8px; background: linear-gradient(#e5e7eb 1px, transparent 1px), - linear-gradient(90deg, #e5e7eb 1px, transparent 1px), - #f8fafc; + linear-gradient(90deg, #e5e7eb 1px, transparent 1px), #f8fafc; background-size: 24px 24px; color: #475569; font-weight: 700; diff --git a/src/domain/deepLink/puzzlinkDeepLink.test.ts b/src/domain/deepLink/puzzlinkDeepLink.test.ts new file mode 100644 index 0000000..3f9b69f --- /dev/null +++ b/src/domain/deepLink/puzzlinkDeepLink.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + PUZZLINK_DEEP_LINK_MAX_GRID_SIZE, + parsePuzzlinkDeepLink, +} from './puzzlinkDeepLink' + +const SLITHER_PAYLOAD = + 'slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' +const MASYU_PAYLOAD = 'mashu/14/8/330000096960006ik00039a00010j0i0000220' + +describe('parsePuzzlinkDeepLink', () => { + it('does nothing when the p parameter is absent', () => { + expect(parsePuzzlinkDeepLink('?other=value')).toEqual({ status: 'absent' }) + }) + + it.each([ + [SLITHER_PAYLOAD, 'slitherlink', 10, 10], + [MASYU_PAYLOAD, 'masyu', 8, 14], + ])('loads canonical payload %s', (payload, pluginId, rows, cols) => { + const result = parsePuzzlinkDeepLink(`?p=${encodeURIComponent(payload)}`) + + expect(result.status).toBe('valid') + if (result.status !== 'valid') { + return + } + expect(result.pluginId).toBe(pluginId) + expect(result.sourceUrl).toBe(`https://puzz.link/p?${payload}`) + expect(result.puzzle.rows).toBe(rows) + expect(result.puzzle.cols).toBe(cols) + }) + + it.each([ + '?p=', + '?p=slither%2F3%2F3%2Fg0h&p=mashu%2F3%2F3%2F000', + '?p=slitherlink%2F3%2F3%2Fg0h', + '?p=masyu%2F3%2F3%2F000', + '?p=https%3A%2F%2Fpuzz.link%2Fp%3Fslither%2F3%2F3%2Fg0h', + '?p=slither%2F10%2F10%2Fdsew%3F', + `?p=slither%2F${PUZZLINK_DEEP_LINK_MAX_GRID_SIZE + 1}%2F3%2Fg0h`, + ])('rejects invalid deep link %s', (search) => { + expect(parsePuzzlinkDeepLink(search).status).toBe('invalid') + }) + + it('rejects a non-canonical payload that a permissive parser could decode', () => { + const result = parsePuzzlinkDeepLink('?p=slither%2F3%2F3%2Fg0hz') + + expect(result.status).toBe('invalid') + if (result.status === 'invalid') { + expect(result.message).toMatch(/canonical/) + } + }) +}) diff --git a/src/domain/deepLink/puzzlinkDeepLink.ts b/src/domain/deepLink/puzzlinkDeepLink.ts new file mode 100644 index 0000000..d565024 --- /dev/null +++ b/src/domain/deepLink/puzzlinkDeepLink.ts @@ -0,0 +1,92 @@ +import type { PuzzleIR } from '../ir/types' +import { puzzleRegistry } from '../plugins/registry' + +export const PUZZLINK_DEEP_LINK_PARAM = 'p' +export const PUZZLINK_DEEP_LINK_MAX_LENGTH = 8192 +export const PUZZLINK_DEEP_LINK_MAX_GRID_SIZE = 100 + +type SupportedDeepLinkType = 'slither' | 'mashu' + +const pluginIds: Record = { + slither: 'slitherlink', + mashu: 'masyu', +} + +export type PuzzlinkDeepLinkResult = + | { status: 'absent' } + | { + status: 'valid' + payload: string + pluginId: string + sourceUrl: string + puzzle: PuzzleIR + } + | { status: 'invalid'; message: string } + +const invalid = (message: string): PuzzlinkDeepLinkResult => ({ + status: 'invalid', + message: `Invalid PuzzleKit deep link: ${message}`, +}) + +const getCanonicalPayload = (url: string): string => { + const parsed = new URL(url) + return decodeURIComponent(parsed.search.replace(/^\?/, '')).split('&')[0] ?? '' +} + +export const parsePuzzlinkDeepLink = (search: string): PuzzlinkDeepLinkResult => { + const params = new URLSearchParams(search) + const values = params.getAll(PUZZLINK_DEEP_LINK_PARAM) + + if (values.length === 0) { + return { status: 'absent' } + } + if (values.length !== 1) { + return invalid('provide exactly one "p" parameter.') + } + + const payload = values[0] + if (!payload) { + return invalid('the "p" parameter cannot be empty.') + } + if (payload.length > PUZZLINK_DEEP_LINK_MAX_LENGTH) { + return invalid(`the payload exceeds ${PUZZLINK_DEEP_LINK_MAX_LENGTH} characters.`) + } + + const match = /^(slither|mashu)\/([1-9]\d*)\/([1-9]\d*)\/([0-9a-z.]+)$/.exec(payload) + if (!match) { + return invalid('expected canonical "slither///" or "mashu///" data.') + } + + const type = match[1] as SupportedDeepLinkType + const colsText = match[2] + const rowsText = match[3] + const cols = Number(colsText) + const rows = Number(rowsText) + if ( + !Number.isSafeInteger(cols) || + !Number.isSafeInteger(rows) || + cols > PUZZLINK_DEEP_LINK_MAX_GRID_SIZE || + rows > PUZZLINK_DEEP_LINK_MAX_GRID_SIZE + ) { + return invalid(`grid dimensions must not exceed ${PUZZLINK_DEEP_LINK_MAX_GRID_SIZE} x ${PUZZLINK_DEEP_LINK_MAX_GRID_SIZE}.`) + } + + const pluginId = pluginIds[type] + const plugin = puzzleRegistry.get(pluginId) + if (!plugin) { + return invalid(`puzzle plugin "${pluginId}" is unavailable.`) + } + + const sourceUrl = `https://puzz.link/p?${payload}` + try { + const puzzle = plugin.parse(sourceUrl) + const canonicalPayload = getCanonicalPayload(plugin.encode(puzzle)) + if (canonicalPayload !== payload) { + return invalid('the payload is damaged, incomplete, or not in canonical puzz.link form.') + } + return { status: 'valid', payload, pluginId, sourceUrl, puzzle } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return invalid(message) + } +} diff --git a/src/domain/difficulty/traceStats.test.ts b/src/domain/difficulty/traceStats.test.ts index e0360cd..41aabc9 100644 --- a/src/domain/difficulty/traceStats.test.ts +++ b/src/domain/difficulty/traceStats.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { cellKey, edgeKey, vertexKey } from '../ir/keys' +import { cellKey, edgeKey, sectorKey, tileKey, vertexKey } from '../ir/keys' import { createMasyuPuzzle } from '../ir/masyu' import { createSlitherPuzzle } from '../ir/slither' import type { RuleStep } from '../rules/types' @@ -113,10 +113,10 @@ describe('buildTraceChartStats', () => { expect(stats.points).toHaveLength(1) expect(stats.current.step).toBe(0) - expect(stats.current.boardProgressRatio).toBe(0) - expect(stats.totalEdges).toBe(4) - expect(stats.totalCells).toBe(1) - expect(stats.totalVertices).toBe(4) + expect(stats.current.coverageRatios.edge).toBe(0) + expect(stats.coverageTotals).toMatchObject({ edge: 4, cell: 1, vertex: 4, sector: 4 }) + expect(stats.current.stepDurationMs).toBe(0) + expect(stats.current.ruleApplyMs).toBe(0) }) it('tracks edge board progress and edge coverage as edge diffs are applied', () => { @@ -134,10 +134,9 @@ describe('buildTraceChartStats', () => { const stats = buildTraceChartStats(puzzle, steps, 2) - expect(stats.points.map((point) => point.edgeCoverageRatio)).toEqual([ + expect(stats.points.map((point) => point.coverageRatios.edge)).toEqual([ 0, 0.25, 0.5, ]) - expect(stats.current.boardProgressRatio).toBe(0.5) }) it('tracks Masyu line decisions as board progress', () => { @@ -151,9 +150,9 @@ describe('buildTraceChartStats', () => { const stats = buildTraceChartStats(puzzle, steps, 1) - expect(stats.totalEdges).toBe(1) - expect(stats.current.boardProgressRatio).toBe(1) - expect(stats.current.edgeCoverageRatio).toBe(1) + expect(stats.coverageTotals.line).toBe(1) + expect(stats.current.coverageRatios.line).toBe(1) + expect(stats.current.coverageRatios.edge).toBe(0) }) it('tracks cell coverage from filled cells', () => { @@ -171,7 +170,7 @@ describe('buildTraceChartStats', () => { const stats = buildTraceChartStats(puzzle, steps, 1) - expect(stats.current.cellCoverageRatio).toBe(0.25) + expect(stats.current.coverageRatios.cell).toBe(0.25) }) it('tracks vertex coverage from narrowed vertex candidates', () => { @@ -191,7 +190,46 @@ describe('buildTraceChartStats', () => { const stats = buildTraceChartStats(puzzle, steps, 1) - expect(stats.current.vertexCoverageRatio).toBe(0.25) + expect(stats.current.coverageRatios.vertex).toBe(0.25) + }) + + it('tracks tile colors and narrowed sector constraints', () => { + const masyu = createMasyuPuzzle(1, 1) + const tile = tileKey(0, 0) + const masyuStats = buildTraceChartStats( + masyu, + [makeStep(1, 'tile-rule', 'Tile Rule', 2, [ + { kind: 'tile', tileKey: tile, fromFill: null, toFill: 'green' }, + ])], + 1, + ) + + const slither = createSlitherPuzzle(1, 1) + const sector = sectorKey(0, 0, 'nw') + const slitherStats = buildTraceChartStats( + slither, + [makeStep(1, 'sector-rule', 'Sector Rule', 3, [ + { kind: 'sector', sectorKey: sector, fromMask: 7, toMask: 2 }, + ])], + 1, + ) + + expect(masyuStats.current.coverageRatios.tile).toBe(1 / 4) + expect(slitherStats.current.coverageRatios.sector).toBe(1 / 4) + }) + + it('stores full-step and matched-rule duration at each chart point', () => { + const puzzle = createSlitherPuzzle(1, 1) + const step = { + ...makeStep(1, 'late-rule', 'Late Rule', 50, []), + chainDurationMs: 50, + ruleApplyMs: 7, + } + + const stats = buildTraceChartStats(puzzle, [step], 1) + + expect(stats.points.map((point) => point.stepDurationMs)).toEqual([0, 50]) + expect(stats.points.map((point) => point.ruleApplyMs)).toEqual([0, 7]) }) it('clamps pointer when selecting the current chart point', () => { @@ -216,8 +254,8 @@ describe('incremental trace stats cache', () => { expect(cache.points).toHaveLength(1) expect(view.current.step).toBe(0) - expect(view.current.boardProgressRatio).toBe(0) - expect(view.totalEdges).toBe(4) + expect(view.current.coverageRatios.edge).toBe(0) + expect(view.coverageTotals.edge).toBe(4) }) it('increments edge, cell, and vertex coverage from appended diffs', () => { @@ -248,9 +286,9 @@ describe('incremental trace stats cache', () => { const cache = appendTraceStatsStep(createTraceStatsCache(puzzle), step) const view = buildTraceStatsView(cache, 1) - expect(view.current.edgeCoverageRatio).toBe(1 / 12) - expect(view.current.cellCoverageRatio).toBe(0.25) - expect(view.current.vertexCoverageRatio).toBe(1 / 9) + expect(view.current.coverageRatios.edge).toBe(1 / 12) + expect(view.current.coverageRatios.cell).toBe(0.25) + expect(view.current.coverageRatios.vertex).toBe(1 / 9) expect(view.totalDurationMs).toBe(4) expect(view.diffCounts).toMatchObject({ edge: 1, cell: 1, vertex: 1 }) }) @@ -270,7 +308,7 @@ describe('incremental trace stats cache', () => { const cache = appendTraceStatsStep(createTraceStatsCache(puzzle), step) - expect(buildTraceStatsView(cache, 1).current.vertexCoverageRatio).toBe(0) + expect(buildTraceStatsView(cache, 1).current.coverageRatios.vertex).toBe(0) }) it('truncates a future branch and rebuilds prefix totals', () => { @@ -299,7 +337,7 @@ describe('incremental trace stats cache', () => { expect(truncated.points).toHaveLength(2) expect(view.totalDurationMs).toBe(2) expect(view.rules.map((rule) => rule.ruleId)).toEqual(['rule-a']) - expect(view.current.edgeCoverageRatio).toBe(0.25) + expect(view.current.coverageRatios.edge).toBe(0.25) }) it('keeps full generated rule rows visible while building an earlier pointer view', () => { diff --git a/src/domain/difficulty/traceStats.ts b/src/domain/difficulty/traceStats.ts index 0260a64..992dbf0 100644 --- a/src/domain/difficulty/traceStats.ts +++ b/src/domain/difficulty/traceStats.ts @@ -1,4 +1,6 @@ import type { PuzzleIR, VertexCandidate } from '../ir/types' +import { SECTOR_MASK_ALL } from '../ir/types' +import type { LiveStatsCoverageSource } from '../plugins/types' import type { RuleDiff, RuleStep } from '../rules/types' export type RuleTraceDiffCounts = Record @@ -28,18 +30,15 @@ export type RuleTraceStats = { export type TraceChartPoint = { step: number - boardProgressRatio: number - edgeCoverageRatio: number - cellCoverageRatio: number - vertexCoverageRatio: number + coverageRatios: Record + stepDurationMs: number + ruleApplyMs: number } export type TraceChartStats = { pointer: number totalSteps: number - totalEdges: number - totalCells: number - totalVertices: number + coverageTotals: Record current: TraceChartPoint points: TraceChartPoint[] } @@ -52,9 +51,8 @@ export type RuleTraceOccurrence = { } export type TraceStatsCache = { - totalEdges: number - totalCells: number - totalVertices: number + coverageTotals: Record + coverageCounts: Record points: TraceChartPoint[] ruleOrder: string[] ruleOccurrences: Record @@ -62,14 +60,15 @@ export type TraceStatsCache = { totalDiffPrefixCounts: number[] diffPrefixCounts: Record edgeMarks: Record + lineMarks: Record cellFills: Record + tileFills: Record + sectorMasks: Record + narrowedSectorKeys: Record vertexCandidateSignatures: Record initialVertexCandidateCounts: Record initialVertexCandidateSignatures: Record narrowedVertexKeys: Record - decidedEdgeCount: number - filledCellCount: number - narrowedVertexCount: number } export const emptyDiffCounts = (): RuleTraceDiffCounts => ({ @@ -180,21 +179,19 @@ const vertexSignature = (candidates: VertexCandidate[] | undefined): string => const makeChartPoint = ( step: number, - cache: Pick< - TraceStatsCache, - | 'decidedEdgeCount' - | 'filledCellCount' - | 'narrowedVertexCount' - | 'totalEdges' - | 'totalCells' - | 'totalVertices' - >, + cache: Pick, + stepDurationMs = 0, + ruleApplyMs = 0, ): TraceChartPoint => ({ step, - boardProgressRatio: ratio(cache.decidedEdgeCount, cache.totalEdges), - edgeCoverageRatio: ratio(cache.decidedEdgeCount, cache.totalEdges), - cellCoverageRatio: ratio(cache.filledCellCount, cache.totalCells), - vertexCoverageRatio: ratio(cache.narrowedVertexCount, cache.totalVertices), + coverageRatios: Object.fromEntries( + (Object.keys(cache.coverageTotals) as LiveStatsCoverageSource[]).map((source) => [ + source, + ratio(cache.coverageCounts[source], cache.coverageTotals[source]), + ]), + ) as Record, + stepDurationMs, + ruleApplyMs, }) const countInitialFilledCells = (puzzle: PuzzleIR): number => @@ -202,10 +199,18 @@ const countInitialFilledCells = (puzzle: PuzzleIR): number => (cell) => cell.fill !== undefined && cell.fill !== null, ).length -const countInitialDecidedEdges = (puzzle: PuzzleIR): number => - Object.values( - puzzle.puzzleType === 'masyu' ? (puzzle.lines ?? {}) : puzzle.edges, - ).filter((edge) => (edge?.mark ?? 'unknown') !== 'unknown').length +const countDecidedMarks = ( + marks: Record, +): number => Object.values(marks).filter((item) => item.mark !== 'unknown').length + +const emptyCoverageRecord = (): Record => ({ + edge: 0, + line: 0, + cell: 0, + tile: 0, + vertex: 0, + sector: 0, +}) const upperBound = (values: number[], target: number): number => { let low = 0 @@ -224,26 +229,34 @@ const upperBound = (values: number[], target: number): number => { export const createTraceStatsCache = ( initialPuzzle: PuzzleIR, ): TraceStatsCache => { - const decisionMarks = - initialPuzzle.puzzleType === 'masyu' - ? (initialPuzzle.lines ?? {}) - : initialPuzzle.edges - const totalEdges = Object.keys(decisionMarks).length - const totalCells = initialPuzzle.rows * initialPuzzle.cols - const totalVertices = Object.keys(initialPuzzle.vertices).length const edgeMarks: Record = {} + const lineMarks: Record = {} const cellFills: Record = {} + const tileFills: Record = {} + const sectorMasks: Record = {} + const narrowedSectorKeys: Record = {} const vertexCandidateSignatures: Record = {} const initialVertexCandidateCounts: Record = {} const initialVertexCandidateSignatures: Record = {} const narrowedVertexKeys: Record = {} - for (const [key, edge] of Object.entries(decisionMarks)) { + for (const [key, edge] of Object.entries(initialPuzzle.edges)) { edgeMarks[key] = edge?.mark ?? 'unknown' } + for (const [key, line] of Object.entries(initialPuzzle.lines)) { + lineMarks[key] = line?.mark ?? 'unknown' + } for (const [key, cell] of Object.entries(initialPuzzle.cells)) { cellFills[key] = cell.fill ?? null } + for (const [key, tile] of Object.entries(initialPuzzle.tiles)) { + tileFills[key] = tile.fill ?? null + } + for (const [key, sector] of Object.entries(initialPuzzle.sectors)) { + const mask = sector.constraintsMask ?? SECTOR_MASK_ALL + sectorMasks[key] = mask + narrowedSectorKeys[key] = mask !== SECTOR_MASK_ALL + } for (const [key, vertex] of Object.entries(initialPuzzle.vertices)) { const candidates = vertex?.candidateEdgeSets ?? [] const signature = vertexSignature(candidates) @@ -253,19 +266,38 @@ export const createTraceStatsCache = ( narrowedVertexKeys[key] = false } + const coverageTotals: Record = { + edge: Object.keys(initialPuzzle.edges).length, + line: Object.keys(initialPuzzle.lines).length, + cell: initialPuzzle.rows * initialPuzzle.cols, + tile: Object.keys(initialPuzzle.tiles).length, + vertex: Object.keys(initialPuzzle.vertices).length, + sector: Object.keys(initialPuzzle.sectors).length, + } + const coverageCounts: Record = { + ...emptyCoverageRecord(), + edge: countDecidedMarks(initialPuzzle.edges), + line: countDecidedMarks(initialPuzzle.lines), + cell: countInitialFilledCells(initialPuzzle), + tile: Object.values(initialPuzzle.tiles).filter((tile) => tile.fill != null).length, + sector: Object.values(initialPuzzle.sectors).filter( + (sector) => sector.constraintsMask !== SECTOR_MASK_ALL, + ).length, + } + const cacheBase = { - totalEdges, - totalCells, - totalVertices, + coverageTotals, + coverageCounts, edgeMarks, + lineMarks, cellFills, + tileFills, + sectorMasks, + narrowedSectorKeys, vertexCandidateSignatures, initialVertexCandidateCounts, initialVertexCandidateSignatures, narrowedVertexKeys, - decidedEdgeCount: countInitialDecidedEdges(initialPuzzle), - filledCellCount: countInitialFilledCells(initialPuzzle), - narrowedVertexCount: 0, } return { @@ -305,33 +337,44 @@ export const appendTraceStatsStep = ( for (const diff of step.diffs) { if (diff.kind === 'edge' || diff.kind === 'line') { const key = diff.kind === 'edge' ? diff.edgeKey : diff.lineKey + const source = diff.kind + const marks = diff.kind === 'edge' ? next.edgeMarks : next.lineMarks if (diff.kind === 'edge') { edgeDiffs += 1 } else { lineDiffs += 1 } - const previous = next.edgeMarks[key] ?? diff.from ?? 'unknown' + const previous = marks[key] ?? diff.from ?? 'unknown' const previousDecided = previous !== 'unknown' const nextDecided = diff.to !== 'unknown' if (!previousDecided && nextDecided) { - next.decidedEdgeCount += 1 + next.coverageCounts[source] += 1 } else if (previousDecided && !nextDecided) { - next.decidedEdgeCount -= 1 + next.coverageCounts[source] -= 1 } - next.edgeMarks[key] = diff.to + marks[key] = diff.to } else if (diff.kind === 'cell') { cellDiffs += 1 const previous = next.cellFills[diff.cellKey] ?? null const previousFilled = previous !== null const nextFilled = diff.toFill !== null if (!previousFilled && nextFilled) { - next.filledCellCount += 1 + next.coverageCounts.cell += 1 } else if (previousFilled && !nextFilled) { - next.filledCellCount -= 1 + next.coverageCounts.cell -= 1 } next.cellFills[diff.cellKey] = diff.toFill } else if (diff.kind === 'tile') { tileDiffs += 1 + const previous = next.tileFills[diff.tileKey] ?? null + const previousFilled = previous !== null + const nextFilled = diff.toFill !== null + if (!previousFilled && nextFilled) { + next.coverageCounts.tile += 1 + } else if (previousFilled && !nextFilled) { + next.coverageCounts.tile -= 1 + } + next.tileFills[diff.tileKey] = diff.toFill } else if (diff.kind === 'vertex') { vertexDiffs += 1 const signature = vertexSignature(diff.toCandidates) @@ -344,14 +387,23 @@ export const appendTraceStatsStep = ( diff.toCandidates.length < initialCount || signature !== initialSignature if (!wasNarrowed && isNarrowed) { - next.narrowedVertexCount += 1 + next.coverageCounts.vertex += 1 } else if (wasNarrowed && !isNarrowed) { - next.narrowedVertexCount -= 1 + next.coverageCounts.vertex -= 1 } next.narrowedVertexKeys[diff.vertexKey] = isNarrowed next.vertexCandidateSignatures[diff.vertexKey] = signature } else { sectorDiffs += 1 + const wasNarrowed = next.narrowedSectorKeys[diff.sectorKey] ?? false + const isNarrowed = diff.toMask !== SECTOR_MASK_ALL + if (!wasNarrowed && isNarrowed) { + next.coverageCounts.sector += 1 + } else if (wasNarrowed && !isNarrowed) { + next.coverageCounts.sector -= 1 + } + next.narrowedSectorKeys[diff.sectorKey] = isNarrowed + next.sectorMasks[diff.sectorKey] = diff.toMask } } @@ -403,7 +455,14 @@ export const appendTraceStatsStep = ( next.diffPrefixCounts.vertex[next.diffPrefixCounts.vertex.length - 1] + vertexDiffs, ) - next.points.push(makeChartPoint(stepNumber, next)) + next.points.push( + makeChartPoint( + stepNumber, + next, + getStepChainDurationMs(step), + getStepRuleApplyMs(step), + ), + ) return { ...next } } @@ -478,9 +537,7 @@ export const buildTraceStatsView = ( ruleUsage, ruleSteps, rules, - totalEdges: cache.totalEdges, - totalCells: cache.totalCells, - totalVertices: cache.totalVertices, + coverageTotals: cache.coverageTotals, current: cache.points[currentPointer] ?? cache.points[0], points: cache.points, } @@ -491,85 +548,13 @@ export const buildTraceChartStats = ( steps: RuleStep[], pointer: number, ): TraceChartStats => { - const currentPointer = clampPointer(pointer, steps.length) - const decisionMarks = - initialPuzzle.puzzleType === 'masyu' - ? (initialPuzzle.lines ?? {}) - : initialPuzzle.edges - const totalEdges = Object.keys(decisionMarks).length - const totalCells = initialPuzzle.rows * initialPuzzle.cols - const totalVertices = Object.keys(initialPuzzle.vertices).length - - const edgeMarks: Record = {} - const cellFills: Record = {} - const initialVertexCandidateCounts: Record = {} - const initialVertexSignatures: Record = {} - const vertexCandidates: Record = {} - - for (const [key, edge] of Object.entries(decisionMarks)) { - edgeMarks[key] = edge?.mark ?? 'unknown' - } - for (const [key, cell] of Object.entries(initialPuzzle.cells)) { - cellFills[key] = cell.fill ?? null - } - for (const [key, vertex] of Object.entries(initialPuzzle.vertices)) { - const candidates = vertex?.candidateEdgeSets ?? [] - initialVertexCandidateCounts[key] = candidates.length - initialVertexSignatures[key] = vertexSignature(candidates) - vertexCandidates[key] = candidates.map((candidate) => [...candidate]) - } - - const makePoint = (step: number): TraceChartPoint => { - const decidedEdges = Object.values(edgeMarks).filter( - (mark) => mark !== 'unknown', - ).length - const filledCells = Object.values(cellFills).filter( - (fill) => fill !== null, - ).length - const narrowedVertices = Object.entries(vertexCandidates).filter( - ([key, candidates]) => { - const initialCount = initialVertexCandidateCounts[key] ?? 0 - return ( - candidates.length < initialCount || - vertexSignature(candidates) !== initialVertexSignatures[key] - ) - }, - ).length - - return { - step, - boardProgressRatio: ratio(decidedEdges, totalEdges), - edgeCoverageRatio: ratio(decidedEdges, totalEdges), - cellCoverageRatio: ratio(filledCells, totalCells), - vertexCoverageRatio: ratio(narrowedVertices, totalVertices), - } - } - - const points: TraceChartPoint[] = [makePoint(0)] - steps.forEach((step, index) => { - for (const diff of step.diffs) { - if (diff.kind === 'edge' || diff.kind === 'line') { - edgeMarks[diff.kind === 'edge' ? diff.edgeKey : diff.lineKey] = diff.to - } else if (diff.kind === 'cell') { - cellFills[diff.cellKey] = diff.toFill - } else if (diff.kind === 'tile') { - continue - } else if (diff.kind === 'vertex') { - vertexCandidates[diff.vertexKey] = diff.toCandidates.map( - (candidate) => [...candidate], - ) - } - } - points.push(makePoint(index + 1)) - }) - + const cache = rebuildTraceStatsCache(initialPuzzle, steps) + const view = buildTraceStatsView(cache, pointer) return { - pointer: currentPointer, - totalSteps: steps.length, - totalEdges, - totalCells, - totalVertices, - current: points[currentPointer] ?? points[0], - points, + pointer: view.pointer, + totalSteps: view.totalSteps, + coverageTotals: view.coverageTotals, + current: view.current, + points: view.points, } } diff --git a/src/domain/plugins/masyuPlugin.ts b/src/domain/plugins/masyuPlugin.ts index 4083cff..55564d1 100644 --- a/src/domain/plugins/masyuPlugin.ts +++ b/src/domain/plugins/masyuPlugin.ts @@ -127,6 +127,14 @@ export const getMasyuStats = (puzzle: PuzzleIR): PuzzleStatsContent => { export const masyuPlugin: PuzzlePlugin = { id: 'masyu', displayName: 'Masyu', + liveStats: { + coverageTitle: 'Inference Coverage', + coverageDescription: 'Decided or colored Masyu state', + coverageSeries: [ + { source: 'line', label: 'Line Decisions', color: '#2563eb' }, + { source: 'tile', label: 'Tile Colors', color: '#16a34a' }, + ], + }, help: masyuHelp, legend: masyuLegend, displayOptions: [ diff --git a/src/domain/plugins/slitherPlugin.ts b/src/domain/plugins/slitherPlugin.ts index f711ee9..0ed0001 100644 --- a/src/domain/plugins/slitherPlugin.ts +++ b/src/domain/plugins/slitherPlugin.ts @@ -261,6 +261,16 @@ export const getSlitherStats = (puzzle: PuzzleIR): PuzzleStatsContent => { export const slitherPlugin: PuzzlePlugin = { id: 'slitherlink', displayName: 'Slitherlink', + liveStats: { + coverageTitle: 'Inference Coverage', + coverageDescription: 'Decided or narrowed Slitherlink state', + coverageSeries: [ + { source: 'edge', label: 'Edge Decisions', color: '#2563eb' }, + { source: 'cell', label: 'Cell Colors', color: '#16a34a' }, + { source: 'vertex', label: 'Vertex Candidates', color: '#d97706' }, + { source: 'sector', label: 'Sector Constraints', color: '#9333ea' }, + ], + }, help: slitherHelp, legend: slitherLegend, displayOptions: [ diff --git a/src/domain/plugins/types.ts b/src/domain/plugins/types.ts index 6a5f433..0677cac 100644 --- a/src/domain/plugins/types.ts +++ b/src/domain/plugins/types.ts @@ -78,12 +78,33 @@ export type PuzzleDisplayOption = { description?: string } +export type LiveStatsCoverageSource = + | 'edge' + | 'line' + | 'cell' + | 'tile' + | 'vertex' + | 'sector' + +export type LiveStatsCoverageSeries = { + source: LiveStatsCoverageSource + label: string + color: string +} + +export type PuzzleLiveStatsConfig = { + coverageTitle: string + coverageDescription: string + coverageSeries: LiveStatsCoverageSeries[] +} + export interface PuzzlePlugin { id: string displayName: string help?: PuzzleHelpContent legend?: PuzzleLegendContent displayOptions?: PuzzleDisplayOption[] + liveStats?: PuzzleLiveStatsConfig getStats?: (puzzle: PuzzleIR) => PuzzleStatsContent | null parse: (input: string) => PuzzleIR encode: (puzzle: PuzzleIR) => string diff --git a/src/domain/rules/engine.ts b/src/domain/rules/engine.ts index 2d182dd..3763260 100644 --- a/src/domain/rules/engine.ts +++ b/src/domain/rules/engine.ts @@ -206,6 +206,7 @@ export const runNextRule = ( chainDurationMs, ruleApplyMs, ruleAttempts: attempts, + inferenceDetails: result.inferenceDetails, } return { nextPuzzle: applyDiffs(puzzle, step), diff --git a/src/domain/rules/masyu/rules.test.ts b/src/domain/rules/masyu/rules.test.ts deleted file mode 100644 index b0c58bd..0000000 --- a/src/domain/rules/masyu/rules.test.ts +++ /dev/null @@ -1,2681 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { cellKey, lineKey, tileKey } from '../../ir/keys' -import { createMasyuPuzzle } from '../../ir/masyu' -import type { LineMark, PuzzleIR } from '../../ir/types' -import { runNextRule } from '../engine' -import type { Rule } from '../types' -import { masyuPlugin } from '../../plugins/masyuPlugin' -import { createBlackPearlStrongInferenceRule } from './rules/blackPearlStrongInference' -import { createMasyuCandidateBridgeLineRule } from './rules/bridges' -import { - createAdjacentWhitePearlsLookaheadRule, - createBlackPearlCandidatePruningRule, - createEmptyCellCandidatePruningRule, - createWhitePearlCandidatePruningRule, -} from './rules/candidates' -import { - createMasyuColorLinePropagationRule, - createMasyuColorPearlPropagationRule, - createMasyuTileColorPropagationRule, -} from './rules/color' -import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' -import { createCellExitCompletionRule } from './rules/completion' -import { - createMasyuEmptyCellPrematureLoopRule, - createPreventPrematureLoopRule, -} from './rules/loop' -import { - createBlackDiagonalWhitePinchRule, - createBlackFacingConsecutiveWhitesRule, - createConsecutiveWhitePearlsStraightRule, - createDoubleBlackSqueezeRule, - createWhiteCorridorRule, -} from './rules/patterns' -import { createBlackPearlRule, createWhitePearlRule } from './rules/pearls' -import { deterministicMasyuRules } from './rules' -import { createWhitePearlStrongInferenceRule } from './rules/whitePearlStrongInference' -import { - createMasyuLineDecisionCollector, - createMasyuTileDecisionCollector, -} from './rules/decisionCollector' -import { createEmptyCellStrongInferenceRule } from './rules/emptyCellStrongInference' -import { - getMasyuBlackPearlKeys, - getMasyuPearlColor, - getMasyuPearlKeys, - getMasyuWhitePearlKeys, -} from './rules/pearlSelectors' -import { buildMasyuTileParityGraph } from './rules/tileParity' -import { - buildMasyuCandidateGraph, - findMasyuPrematureLoopClosingLines, - getMasyuRequiredSources, -} from './rules/lineGraph' -import { createMasyuLookaheadContext } from './rules/lookahead' -import { findMasyuHardContradictionReason } from './rules/trial' - -const markLine = (puzzle: PuzzleIR, key: string, mark: LineMark): void => { - puzzle.lines[key] = { ...puzzle.lines[key], mark } -} - -const addPearl = ( - puzzle: PuzzleIR, - row: number, - col: number, - color: 'white' | 'black', -): void => { - puzzle.cells[cellKey(row, col)] = { clue: { kind: 'pearl', color } } -} - -const fillAllTiles = (puzzle: PuzzleIR, fill: 'green' | 'yellow'): void => { - for (let row = 0; row <= puzzle.rows; row += 1) { - for (let col = 0; col <= puzzle.cols; col += 1) { - puzzle.tiles[tileKey(row, col)] = { fill } - } - } -} - -const getLineDegree = (puzzle: PuzzleIR, row: number, col: number): number => - [ - row > 0 ? lineKey([row - 1, col], [row, col]) : null, - row < puzzle.rows - 1 ? lineKey([row, col], [row + 1, col]) : null, - col > 0 ? lineKey([row, col - 1], [row, col]) : null, - col < puzzle.cols - 1 ? lineKey([row, col], [row, col + 1]) : null, - ].filter((key) => key !== null && puzzle.lines[key]?.mark === 'line').length - -const expectLineDiffs = ( - diffs: - | NonNullable< - ReturnType['apply']> - >['diffs'] - | undefined, - expected: Record, -): void => { - expect( - Object.fromEntries( - (diffs ?? []).map((diff) => [ - diff.kind === 'line' ? diff.lineKey : '', - diff.kind === 'line' ? diff.to : '', - ]), - ), - ).toEqual(expected) -} - -describe('Masyu pearl rules', () => { - it('White Pearl Rule blanks a blocked axis and forces the other straight axis', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 0, 1, 'white') - const south = lineKey([0, 1], [1, 1]) - const east = lineKey([0, 1], [0, 2]) - const west = lineKey([0, 1], [0, 0]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [south]: 'blank', - [east]: 'line', - [west]: 'line', - }) - expect(result?.affectedCells).toEqual([cellKey(0, 1)]) - }) - - it('White Pearl Rule uses an existing blank to force the crossing axis', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 1, 1, 'white') - const east = lineKey([1, 1], [1, 2]) - const west = lineKey([1, 1], [1, 0]) - const north = lineKey([1, 1], [0, 1]) - const south = lineKey([1, 1], [2, 1]) - markLine(puzzle, east, 'blank') - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [west]: 'blank', - [north]: 'line', - [south]: 'line', - }) - }) - - it('White Pearl Rule does nothing when both straight axes are still available', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 1, 1, 'white') - - expect(createWhitePearlRule().apply(puzzle)).toBeNull() - }) - - it('White Pearl Rule rejects a vertical pass-through when both immediate turn cells are blocked', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'white') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') - markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') - markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') - const north = lineKey([2, 3], [3, 3]) - const south = lineKey([3, 3], [4, 3]) - const east = lineKey([3, 3], [3, 4]) - const west = lineKey([3, 2], [3, 3]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [north]: 'blank', - [south]: 'blank', - [east]: 'line', - [west]: 'line', - }) - }) - - it('White Pearl Rule rejects a horizontal pass-through when both immediate turn cells are blocked', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'white') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') - markLine(puzzle, lineKey([2, 4], [3, 4]), 'blank') - markLine(puzzle, lineKey([3, 4], [4, 4]), 'blank') - const east = lineKey([3, 3], [3, 4]) - const west = lineKey([3, 2], [3, 3]) - const north = lineKey([2, 3], [3, 3]) - const south = lineKey([3, 3], [4, 3]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'blank', - [west]: 'blank', - [north]: 'line', - [south]: 'line', - }) - }) - - it('White Pearl Rule keeps an axis available when each side still has a turn candidate', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'white') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') - - expect(createWhitePearlRule().apply(puzzle)).toBeNull() - }) - - it('White Pearl Rule keeps an axis available when only one side can turn', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'white') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') - - expect(createWhitePearlRule().apply(puzzle)).toBeNull() - }) - - it('White Pearl Rule does not force the second white pearl vertical when the shared gap can still turn', () => { - const puzzle = createMasyuPuzzle(9, 10) - addPearl(puzzle, 4, 5, 'white') - addPearl(puzzle, 4, 7, 'white') - markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') - markLine(puzzle, lineKey([4, 5], [4, 6]), 'line') - markLine(puzzle, lineKey([4, 6], [5, 6]), 'blank') - const result = createWhitePearlRule().apply(puzzle) - - expect(result?.diffs).not.toContainEqual({ - kind: 'line', - lineKey: lineKey([3, 7], [4, 7]), - from: 'unknown', - to: 'line', - }) - expect(result?.diffs).not.toContainEqual({ - kind: 'line', - lineKey: lineKey([4, 7], [5, 7]), - from: 'unknown', - to: 'line', - }) - }) - - it('White Pearl Rule rejects a horizontal pass-through when both side turns break adjacent pearls', () => { - const puzzle = createMasyuPuzzle(10, 10) - addPearl(puzzle, 3, 3, 'black') - addPearl(puzzle, 5, 3, 'black') - addPearl(puzzle, 4, 4, 'white') - addPearl(puzzle, 4, 5, 'white') - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([4, 3], [4, 4])]: 'blank', - [lineKey([4, 4], [4, 5])]: 'blank', - [lineKey([3, 4], [4, 4])]: 'line', - [lineKey([4, 4], [5, 4])]: 'line', - }) - expect(result?.affectedCells).toEqual([cellKey(4, 4)]) - }) - - it('White Pearl Rule treats an adjacent white pearl as a blocked turn side', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'white') - addPearl(puzzle, 2, 3, 'white') - markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') - markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 3], [3, 3])]: 'blank', - [lineKey([3, 3], [4, 3])]: 'blank', - [lineKey([3, 3], [3, 4])]: 'line', - [lineKey([3, 2], [3, 3])]: 'line', - }) - }) - - it('White Pearl Rule keeps an axis available when a nearby black pearl still has candidates', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 2, 'white') - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 2], [3, 2])]: 'blank', - [lineKey([3, 2], [3, 3])]: 'line', - [lineKey([3, 1], [3, 2])]: 'line', - }) - }) - - it('White Pearl Rule keeps a turn side available when only one target is black', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'white') - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') - markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') - - expect(createWhitePearlRule().apply(puzzle)).toBeNull() - }) - - it('White Pearl Rule continues a known line straight and blanks turn candidates', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - const east = lineKey([2, 2], [2, 3]) - const north = lineKey([1, 2], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'line', - [north]: 'blank', - [south]: 'blank', - }) - }) - - it('White Pearl Rule does not force a straight line into a degree-2 neighbor', () => { - const puzzle = createMasyuPuzzle(4, 4) - addPearl(puzzle, 1, 2, 'white') - markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - - const result = createWhitePearlRule().apply(puzzle) - - expect(result?.diffs).not.toContainEqual({ - kind: 'line', - lineKey: lineKey([1, 1], [1, 2]), - from: 'unknown', - to: 'line', - }) - }) - - it('White Pearl Rule blanks turn candidates when a white pearl already has a straight pair', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - const north = lineKey([1, 2], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) - }) - - it('White Pearl Rule only highlights pearls that create new line decisions', () => { - const puzzle = createMasyuPuzzle(3, 4) - const unchangedPearl = cellKey(1, 1) - const changedPearl = cellKey(1, 2) - addPearl(puzzle, 1, 1, 'white') - addPearl(puzzle, 1, 2, 'white') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'blank') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - const changedLine = lineKey([1, 2], [1, 3]) - - const result = createWhitePearlRule().apply(puzzle) - - expect(result?.affectedCells).toEqual([changedPearl]) - expect(result?.affectedCells).not.toContain(unchangedPearl) - expect(result?.affectedLines).toEqual([changedLine]) - expectLineDiffs(result?.diffs, { [changedLine]: 'blank' }) - }) - - it('White Pearl Rule blocks the short side from continuing when the other side already runs straight south', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') - markLine(puzzle, lineKey([3, 2], [4, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - const northExtension = lineKey([0, 2], [1, 2]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [northExtension]: 'blank' }) - expect(result?.message).toContain('must turn in an adjacent cell') - }) - - it('White Pearl Rule blocks the short side from continuing when the other side already runs straight east', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - markLine(puzzle, lineKey([2, 3], [2, 4]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - const westExtension = lineKey([2, 0], [2, 1]) - - const result = createWhitePearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [westExtension]: 'blank' }) - }) - - it('White Pearl Rule does not block either side when both sides only reach the pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - - expect(createWhitePearlRule().apply(puzzle)).toBeNull() - }) - - it('White Pearl Rule ignores adjacent-turn continuation when the short-side extension leaves the board', () => { - const puzzle = createMasyuPuzzle(4, 4) - addPearl(puzzle, 1, 2, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') - markLine(puzzle, lineKey([1, 2], [1, 3]), 'blank') - - expect(createWhitePearlRule().apply(puzzle)).toBeNull() - }) - - it('Black Pearl Rule forces the opposite line and its straight extension at the border', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 0, 1, 'black') - const south = lineKey([0, 1], [1, 1]) - const extension = lineKey([1, 1], [2, 1]) - - const result = createBlackPearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'line', [extension]: 'line' }) - expect(result?.affectedCells).toEqual([cellKey(0, 1)]) - }) - - it('Black Pearl Rule forces the opposite line and extension when one side is already blank', () => { - const puzzle = createMasyuPuzzle(3, 4) - addPearl(puzzle, 1, 1, 'black') - const west = lineKey([1, 1], [1, 0]) - const east = lineKey([1, 1], [1, 2]) - const extension = lineKey([1, 2], [1, 3]) - markLine(puzzle, west, 'blank') - - const result = createBlackPearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'line', [extension]: 'line' }) - }) - - it('Black Pearl Rule does not overwrite already-decided opposite line or extension', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 0, 1, 'black') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - - expect(createBlackPearlRule().apply(puzzle)).toBeNull() - }) - - it('Black Pearl Rule only highlights pearls that create new line decisions', () => { - const puzzle = createMasyuPuzzle(5, 5) - const unchangedPearl = cellKey(2, 2) - const changedPearl = cellKey(0, 1) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 0, 1, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - const changedLine = lineKey([1, 1], [2, 1]) - - const result = createBlackPearlRule().apply(puzzle) - - expect(result?.affectedCells).toEqual([changedPearl]) - expect(result?.affectedCells).not.toContain(unchangedPearl) - expect(result?.affectedLines).toEqual([changedLine]) - expectLineDiffs(result?.diffs, { [changedLine]: 'line' }) - }) - - it('Black Pearl Rule rejects an exit whose second step would leave the board', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 4, 'black') - const east = lineKey([3, 4], [3, 5]) - const west = lineKey([3, 3], [3, 4]) - const extension = lineKey([3, 2], [3, 3]) - - const result = createBlackPearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'blank', - [west]: 'line', - [extension]: 'line', - }) - }) - - it('Black Pearl Rule rejects an exit whose second step is already blank', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'black') - const east = lineKey([3, 3], [3, 4]) - const eastExtension = lineKey([3, 4], [3, 5]) - const west = lineKey([3, 2], [3, 3]) - const westExtension = lineKey([3, 1], [3, 2]) - markLine(puzzle, eastExtension, 'blank') - - const result = createBlackPearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'blank', - [west]: 'line', - [westExtension]: 'line', - }) - }) - - it('Black Pearl Rule turns away from a known line and extends that exit', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - const extension = lineKey([2, 0], [2, 1]) - const east = lineKey([2, 2], [2, 3]) - - const result = createBlackPearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'blank', [extension]: 'line' }) - }) - - it('Black Pearl Rule blanks remaining exits and extends both sides of a known turn', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - const east = lineKey([2, 2], [2, 3]) - const south = lineKey([2, 2], [3, 2]) - const westExtension = lineKey([2, 0], [2, 1]) - const northExtension = lineKey([0, 2], [1, 2]) - - const result = createBlackPearlRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'blank', - [south]: 'blank', - [westExtension]: 'line', - [northExtension]: 'line', - }) - }) - - it('registers Masyu rules in pearl-then-completion order', () => { - expect(masyuPlugin.getRules().map((rule) => rule.name)).toEqual([ - 'White Pearl Rule', - 'Black Pearl Rule', - 'Black Facing Consecutive Whites', - 'Black Diagonal White Pinch', - 'Consecutive White Pearls Straight', - 'White Corridor', - 'Double Black Squeeze', - 'Masyu Tile Color Propagation', - 'Masyu Color-Pearl Propagation', - 'Masyu Color-Line Propagation', - 'Masyu Tile Connectivity Cut Coloring', - 'Masyu Candidate Bridge Line', - 'Prevent Premature Loop', - 'Masyu Empty Cell Premature Loop', - 'Black Pearl Candidate Pruning', - 'White Pearl Candidate Pruning', - 'Adjacent White Pearls LookAhead', - 'Cell Exit Completion', - 'Black Pearl Strong Inference', - 'White Pearl Strong Inference', - 'Empty Cell Strong Inference', - ]) - }) - - it('applies a line diff on the sample Masyu puzzle', () => { - const puzzle = masyuPlugin.parse('https://puzz.link/p?mashu/5/5/001390360') - const { step } = runNextRule(puzzle, masyuPlugin.getRules(), 1) - - expect(step?.ruleName).toBe('White Pearl Rule') - expect(step?.diffs.some((diff) => diff.kind === 'line')).toBe(true) - }) -}) - -describe('Masyu loop rules', () => { - it('Masyu Tile Color Propagation seeds boundary tiles yellow', () => { - const puzzle = createMasyuPuzzle(2, 2) - const result = createMasyuTileColorPropagationRule().apply(puzzle) - const fills = new Map( - (result?.diffs ?? []).flatMap((diff) => - diff.kind === 'tile' ? [[diff.tileKey, diff.toFill] as const] : [], - ), - ) - - expect(fills.size).toBe(8) - for (let row = 0; row <= 2; row += 1) { - for (let col = 0; col <= 2; col += 1) { - const key = tileKey(row, col) - if (row === 1 && col === 1) { - expect(fills.has(key)).toBe(false) - } else { - expect(fills.get(key)).toBe('yellow') - } - } - } - }) - - it('Masyu Tile Color Propagation carries color through blank lines', () => { - const puzzle = createMasyuPuzzle(2, 2) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'blank') - - const result = createMasyuTileColorPropagationRule().apply(puzzle) - - expect(result?.diffs).toContainEqual({ - kind: 'tile', - tileKey: tileKey(1, 1), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('Masyu Tile Color Propagation flips color across line segments', () => { - const puzzle = createMasyuPuzzle(2, 2) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - - const result = createMasyuTileColorPropagationRule().apply(puzzle) - - expect(result?.diffs).toContainEqual({ - kind: 'tile', - tileKey: tileKey(1, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('Masyu Tile Color Propagation uses existing tile color anchors', () => { - const puzzle = createMasyuPuzzle(3, 3) - puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } - markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') - - const result = createMasyuTileColorPropagationRule().apply(puzzle) - - expect(result?.diffs).toContainEqual({ - kind: 'tile', - tileKey: tileKey(1, 2), - fromFill: null, - toFill: 'green', - }) - }) - - it('Masyu Color-Pearl Propagation colors the opposite diagonal from a white pearl NW tile', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 1, 1, 'white') - puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } - - const result = createMasyuColorPearlPropagationRule().apply(puzzle) - - expect(result?.diffs).toEqual([ - { - kind: 'tile', - tileKey: tileKey(2, 2), - fromFill: null, - toFill: 'yellow', - }, - ]) - expect(result?.affectedCells).toEqual([cellKey(1, 1)]) - expect(result?.affectedTiles).toEqual([tileKey(1, 1), tileKey(2, 2)]) - expect(result?.message).toContain('White pearl') - }) - - it('Masyu Color-Pearl Propagation colors the opposite diagonal from a white pearl NE tile', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 1, 1, 'white') - puzzle.tiles[tileKey(1, 2)] = { fill: 'yellow' } - - const result = createMasyuColorPearlPropagationRule().apply(puzzle) - - expect(result?.diffs).toEqual([ - { kind: 'tile', tileKey: tileKey(2, 1), fromFill: null, toFill: 'green' }, - ]) - }) - - it('Masyu Color-Pearl Propagation ignores black pearls', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 1, 1, 'black') - puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } - - expect(createMasyuColorPearlPropagationRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Color-Pearl Propagation does not overwrite an already colored opposite diagonal', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 1, 1, 'white') - puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } - puzzle.tiles[tileKey(2, 2)] = { fill: 'green' } - - expect(createMasyuColorPearlPropagationRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Color-Line Propagation forces a line between different adjacent tile colors', () => { - const puzzle = createMasyuPuzzle(3, 3) - const targetLine = lineKey([1, 1], [1, 2]) - puzzle.tiles[tileKey(1, 2)] = { fill: 'green' } - puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } - - const result = createMasyuColorLinePropagationRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [targetLine]: 'line' }) - expect(result?.affectedLines).toEqual([targetLine]) - expect(result?.affectedTiles).toEqual([tileKey(1, 2), tileKey(2, 2)]) - expect(result?.message).toContain('different colors') - }) - - it('Masyu Color-Line Propagation crosses a line between same adjacent tile colors', () => { - const puzzle = createMasyuPuzzle(3, 3) - const targetLine = lineKey([1, 1], [2, 1]) - puzzle.tiles[tileKey(2, 1)] = { fill: 'yellow' } - puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } - - const result = createMasyuColorLinePropagationRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [targetLine]: 'blank' }) - expect(result?.message).toContain('same color') - }) - - it('Masyu Color-Line Propagation uses boundary tile colors beside edge-adjacent lines', () => { - const puzzle = createMasyuPuzzle(2, 2) - const targetLine = lineKey([0, 0], [0, 1]) - puzzle.tiles[tileKey(0, 1)] = { fill: 'yellow' } - puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } - - const result = createMasyuColorLinePropagationRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [targetLine]: 'line' }) - }) - - it('Masyu Color-Line Propagation does not overwrite an already decided line', () => { - const puzzle = createMasyuPuzzle(3, 3) - const targetLine = lineKey([1, 1], [1, 2]) - puzzle.tiles[tileKey(1, 2)] = { fill: 'green' } - puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } - markLine(puzzle, targetLine, 'blank') - - expect(createMasyuColorLinePropagationRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Tile Connectivity Cut Coloring colors an articulation tile green between green sources', () => { - const puzzle = createMasyuPuzzle(4, 4) - fillAllTiles(puzzle, 'yellow') - puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } - puzzle.tiles[tileKey(2, 2)] = {} - puzzle.tiles[tileKey(2, 3)] = { fill: 'green' } - - const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) - - expect(result?.diffs).toEqual([ - { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'green' }, - ]) - expect(result?.affectedTiles).toEqual([tileKey(2, 2)]) - expect(result?.message).toContain('inside cuts 1') - }) - - it('Masyu Tile Connectivity Cut Coloring colors every unknown tile in a blank-compressed bottleneck', () => { - const puzzle = createMasyuPuzzle(4, 5) - fillAllTiles(puzzle, 'yellow') - puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } - puzzle.tiles[tileKey(2, 2)] = {} - puzzle.tiles[tileKey(2, 3)] = {} - puzzle.tiles[tileKey(2, 4)] = { fill: 'green' } - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - - const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) - - expect(result?.diffs).toEqual([ - { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'green' }, - { kind: 'tile', tileKey: tileKey(2, 3), fromFill: null, toFill: 'green' }, - ]) - }) - - it('Masyu Tile Connectivity Cut Coloring colors a line-enclosed tile green', () => { - const puzzle = createMasyuPuzzle(2, 2) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([0, 0], [1, 0]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - - const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) - - expect(result?.diffs).toEqual([ - { kind: 'tile', tileKey: tileKey(1, 1), fromFill: null, toFill: 'green' }, - ]) - expect(result?.message).toContain('unreachable-from-outside 1') - }) - - it('Masyu Tile Connectivity Cut Coloring keeps unknown line separators passable', () => { - const puzzle = createMasyuPuzzle(2, 2) - - expect( - createMasyuTileConnectivityCutColoringRule().apply(puzzle), - ).toBeNull() - }) - - it('Masyu Tile Connectivity Cut Coloring does not fire with only one green source component', () => { - const puzzle = createMasyuPuzzle(4, 4) - fillAllTiles(puzzle, 'yellow') - puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } - puzzle.tiles[tileKey(2, 2)] = {} - - expect( - createMasyuTileConnectivityCutColoringRule().apply(puzzle), - ).toBeNull() - }) - - it('Masyu Candidate Bridge Line forces the only candidate bridge between two pearls', () => { - const puzzle = createMasyuPuzzle(1, 4) - addPearl(puzzle, 0, 0, 'white') - addPearl(puzzle, 0, 3, 'white') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - const bridge = lineKey([0, 1], [0, 2]) - - const result = createMasyuCandidateBridgeLineRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [bridge]: 'line' }) - expect(result?.affectedLines).toEqual([bridge]) - expect(result?.affectedCells).toEqual([cellKey(0, 1), cellKey(0, 2)]) - expect(result?.message).toContain('only remaining connection') - }) - - it('Masyu Candidate Bridge Line does not fire when two required groups have alternate routes', () => { - const puzzle = createMasyuPuzzle(2, 3) - addPearl(puzzle, 0, 0, 'white') - addPearl(puzzle, 0, 2, 'white') - - expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Candidate Bridge Line ignores a bridge into an ordinary dead-end region', () => { - const puzzle = createMasyuPuzzle(1, 3) - addPearl(puzzle, 0, 0, 'white') - addPearl(puzzle, 0, 1, 'white') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - - expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Candidate Bridge Line treats existing line endpoints as required sources', () => { - const puzzle = createMasyuPuzzle(1, 4) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - addPearl(puzzle, 0, 3, 'black') - const bridge = lineKey([0, 1], [0, 2]) - - const result = createMasyuCandidateBridgeLineRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [bridge]: 'line' }) - }) - - it('Masyu Candidate Bridge Line skips a forced bridge that would overflow endpoint degree', () => { - const puzzle = createMasyuPuzzle(3, 4) - addPearl(puzzle, 1, 0, 'white') - addPearl(puzzle, 1, 3, 'white') - for (const key of Object.keys(puzzle.lines)) { - markLine(puzzle, key, 'blank') - } - markLine(puzzle, lineKey([1, 0], [1, 1]), 'unknown') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'unknown') - markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - - expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() - }) - - it('registers Masyu color propagation before premature loop prevention', () => { - const rules = masyuPlugin.getRules().map((rule) => rule.id) - - expect(rules).toContain('masyu-tile-color-propagation') - expect(rules).toContain('masyu-color-pearl-propagation') - expect(rules).toContain('masyu-color-line-propagation') - expect(rules).toContain('masyu-tile-connectivity-cut-coloring') - expect(rules).toContain('masyu-candidate-bridge-line') - expect(rules.indexOf('masyu-color-pearl-propagation')).toBe( - rules.indexOf('masyu-tile-color-propagation') + 1, - ) - expect(rules.indexOf('masyu-color-line-propagation')).toBe( - rules.indexOf('masyu-color-pearl-propagation') + 1, - ) - expect(rules.indexOf('masyu-tile-connectivity-cut-coloring')).toBe( - rules.indexOf('masyu-color-line-propagation') + 1, - ) - expect(rules.indexOf('masyu-candidate-bridge-line')).toBe( - rules.indexOf('masyu-tile-connectivity-cut-coloring') + 1, - ) - expect(rules.indexOf('masyu-candidate-bridge-line')).toBeLessThan( - rules.indexOf('masyu-prevent-premature-loop'), - ) - expect(rules.indexOf('masyu-empty-cell-premature-loop')).toBe( - rules.indexOf('masyu-prevent-premature-loop') + 1, - ) - expect(rules.indexOf('masyu-empty-cell-premature-loop')).toBeLessThan( - rules.indexOf('masyu-black-pearl-candidate-pruning'), - ) - expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBe( - rules.indexOf('masyu-black-pearl-candidate-pruning') + 1, - ) - expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBe( - rules.indexOf('masyu-white-pearl-candidate-pruning') + 1, - ) - expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBeLessThan( - rules.indexOf('cell-exit-completion'), - ) - }) - - it('Prevent Premature Loop blanks a line that would close a smaller loop while other lines remain outside', () => { - const puzzle = createMasyuPuzzle(4, 4) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - const closingLine = lineKey([0, 0], [1, 0]) - - const result = createPreventPrematureLoopRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [closingLine]: 'blank' }) - expect(result?.affectedLines).toEqual([closingLine]) - expect(result?.message).toContain('smaller loop') - }) - - it('Prevent Premature Loop does not blank a candidate whose endpoints are in different components', () => { - const puzzle = createMasyuPuzzle(4, 4) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - - expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() - }) - - it('Prevent Premature Loop allows a closing line when no other confirmed lines remain outside', () => { - const puzzle = createMasyuPuzzle(4, 4) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - - expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Empty Cell Premature Loop blanks both exits when using both would close a smaller loop', () => { - const puzzle = createMasyuPuzzle(4, 4) - const target = cellKey(0, 0) - const east = lineKey([0, 0], [0, 1]) - const south = lineKey([0, 0], [1, 0]) - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - - const result = createMasyuEmptyCellPrematureLoopRule().apply(puzzle) - - expect(result?.affectedCells).toEqual([target]) - expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank' }) - expect(result?.affectedLines).toEqual([east, south]) - expect(result?.message).toContain('both remaining exits') - expect(result?.message).toContain('smaller loop') - }) - - it('Masyu Empty Cell Premature Loop skips cells that already have a line', () => { - const puzzle = createMasyuPuzzle(4, 4) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - - expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() - }) - - it('Masyu Empty Cell Premature Loop skips pearl cells', () => { - for (const color of ['black', 'white'] as const) { - const puzzle = createMasyuPuzzle(4, 4) - addPearl(puzzle, 0, 0, color) - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - - expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() - } - }) - - it('Masyu Empty Cell Premature Loop allows both exits when no other confirmed lines remain outside', () => { - const puzzle = createMasyuPuzzle(4, 4) - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - - expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() - }) -}) - -describe('Masyu black pearl candidate pruning', () => { - it('forces common exit and extension lines from the remaining black pearl candidates', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - const northExtension = lineKey([0, 2], [1, 2]) - const south = lineKey([2, 2], [3, 2]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [northExtension]: 'line', - [south]: 'blank', - }) - expect(result?.affectedCells).toEqual([cellKey(2, 2)]) - }) - - it('removes a black pearl exit when every candidate using it leaves a nearby white pearl impossible', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'black') - addPearl(puzzle, 2, 4, 'white') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') - const north = lineKey([2, 3], [3, 3]) - const west = lineKey([3, 2], [3, 3]) - const east = lineKey([3, 3], [3, 4]) - const northExtension = lineKey([1, 3], [2, 3]) - const westExtension = lineKey([3, 1], [3, 2]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [north]: 'line', - [west]: 'line', - [northExtension]: 'line', - [westExtension]: 'line', - [east]: 'blank', - }) - }) - - it('removes an exit that would give an adjacent black pearl degree 3 through the extension line', () => { - const puzzle = createMasyuPuzzle(6, 7) - addPearl(puzzle, 3, 3, 'black') - addPearl(puzzle, 3, 4, 'black') - markLine(puzzle, lineKey([2, 3], [3, 3]), 'line') - markLine(puzzle, lineKey([2, 4], [3, 4]), 'line') - const east = lineKey([3, 3], [3, 4]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expect(result?.diffs).toContainEqual({ - kind: 'line', - lineKey: lineKey([1, 3], [2, 3]), - from: 'unknown', - to: 'line', - }) - expect(result?.diffs).toContainEqual({ - kind: 'line', - lineKey: east, - from: 'unknown', - to: 'blank', - }) - }) - - it('removes candidates whose required black pearl extension is already blank', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') - const north = lineKey([1, 2], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - const southExtension = lineKey([3, 2], [4, 2]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expect(result?.diffs).toContainEqual({ - kind: 'line', - lineKey: south, - from: 'unknown', - to: 'line', - }) - expect(result?.diffs).toContainEqual({ - kind: 'line', - lineKey: southExtension, - from: 'unknown', - to: 'line', - }) - expect(result?.diffs).toContainEqual({ - kind: 'line', - lineKey: north, - from: 'unknown', - to: 'blank', - }) - }) - - it('removes a black pearl candidate that would close a smaller loop', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') - markLine(puzzle, lineKey([1, 3], [1, 4]), 'line') - markLine(puzzle, lineKey([1, 4], [2, 4]), 'line') - markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') - const northExtension = lineKey([0, 2], [1, 2]) - const east = lineKey([2, 2], [2, 3]) - const west = lineKey([2, 1], [2, 2]) - const westExtension = lineKey([2, 0], [2, 1]) - const south = lineKey([2, 2], [3, 2]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [northExtension]: 'line', - [west]: 'line', - [westExtension]: 'line', - [south]: 'blank', - [east]: 'blank', - }) - }) - - it('allows a black pearl candidate that closes the only confirmed loop component', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') - markLine(puzzle, lineKey([1, 3], [1, 4]), 'line') - markLine(puzzle, lineKey([1, 4], [2, 4]), 'line') - const northExtension = lineKey([0, 2], [1, 2]) - const south = lineKey([2, 2], [3, 2]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [northExtension]: 'line', - [south]: 'blank', - }) - }) - - it('checks affected white pearl dependencies outside the candidate touched cells', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 3, 'black') - addPearl(puzzle, 2, 4, 'white') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') - const north = lineKey([2, 3], [3, 3]) - const west = lineKey([3, 2], [3, 3]) - const east = lineKey([3, 3], [3, 4]) - const northExtension = lineKey([1, 3], [2, 3]) - const westExtension = lineKey([3, 1], [3, 2]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [north]: 'line', - [west]: 'line', - [northExtension]: 'line', - [westExtension]: 'line', - [east]: 'blank', - }) - }) - - it('ignores unrelated impossible pearls inside the old fixed 5x5 scan area', () => { - const puzzle = createMasyuPuzzle(7, 7) - addPearl(puzzle, 3, 3, 'black') - addPearl(puzzle, 5, 1, 'white') - markLine(puzzle, lineKey([3, 2], [3, 3]), 'blank') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') - markLine(puzzle, lineKey([4, 1], [5, 1]), 'blank') - markLine(puzzle, lineKey([5, 0], [5, 1]), 'blank') - markLine(puzzle, lineKey([5, 1], [5, 2]), 'blank') - markLine(puzzle, lineKey([5, 1], [6, 1]), 'blank') - const north = lineKey([2, 3], [3, 3]) - const northExtension = lineKey([1, 3], [2, 3]) - const east = lineKey([3, 3], [3, 4]) - const eastExtension = lineKey([3, 4], [3, 5]) - - const result = createBlackPearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [north]: 'line', - [northExtension]: 'line', - [east]: 'line', - [eastExtension]: 'line', - }) - }) - - it('does not run on a fully determined black pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') - - expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() - }) - - it('rejects a black pearl candidate that leaves an extension endpoint with no second exit', () => { - const puzzle = createMasyuPuzzle(4, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([0, 3], [0, 4]), 'line') - markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') - - expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() - }) - - it('does nothing when all black pearl candidates remain symmetric', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - - expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() - }) - - it('prunes only one black pearl per step on the reported 10x6 regression puzzle', () => { - let puzzle = masyuPlugin.parse( - 'https://puzz.link/p?mashu/10/6/0000b6103260i0902216', - ) - const rules = masyuPlugin - .getRules() - .filter((rule) => rule.id !== 'masyu-color-pearl-propagation') - let pruningStep: NonNullable< - ReturnType['step'] - > | null = null - - for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { - const result = runNextRule(puzzle, rules, stepNumber) - expect(result.step).not.toBeNull() - if (result.step?.ruleName === 'Black Pearl Candidate Pruning') { - pruningStep = result.step - break - } - puzzle = result.nextPuzzle - } - - expect(pruningStep?.affectedCells).toEqual([cellKey(1, 6)]) - expectLineDiffs(pruningStep?.diffs, { - [lineKey([1, 6], [1, 7])]: 'line', - [lineKey([1, 7], [1, 8])]: 'line', - [lineKey([1, 5], [1, 6])]: 'blank', - }) - }) - - it('does not let White Pearl Rule push the reported long puzzle into a degree-3 cell', () => { - const url = - 'https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090' - let puzzle = masyuPlugin.parse(url) - const unsafeLine = lineKey([2, 6], [2, 7]) - - for (let stepNumber = 1; stepNumber <= 35; stepNumber += 1) { - const result = runNextRule(puzzle, masyuPlugin.getRules(), stepNumber) - if (!result.step) { - break - } - expect(result.step.diffs).not.toContainEqual({ - kind: 'line', - lineKey: unsafeLine, - from: 'unknown', - to: 'line', - }) - puzzle = result.nextPuzzle - expect(getLineDegree(puzzle, 2, 6)).toBeLessThanOrEqual(2) - } - }) -}) - -describe('Masyu white pearl candidate pruning', () => { - it('forces the only feasible white pearl straight axis', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 0, 1, 'white') - const east = lineKey([0, 1], [0, 2]) - const west = lineKey([0, 0], [0, 1]) - const south = lineKey([0, 1], [1, 1]) - - const result = createWhitePearlCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'line', - [west]: 'line', - [south]: 'blank', - }) - expect(result?.affectedCells).toEqual([cellKey(0, 1)]) - expect(result?.message).toContain('White pearl') - }) - - it('does not force a white candidate line into a degree-2 neighbor', () => { - const puzzle = createMasyuPuzzle(4, 4) - addPearl(puzzle, 1, 2, 'white') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - - expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() - }) - - it('rejects a white pearl axis that leaves a neighboring empty cell with no second exit', () => { - const puzzle = createMasyuPuzzle(4, 5) - addPearl(puzzle, 1, 2, 'white') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') - markLine(puzzle, lineKey([0, 0], [1, 0]), 'line') - markLine(puzzle, lineKey([1, 0], [2, 0]), 'line') - markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - - expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() - }) - - it('does nothing when both white straight-axis candidates remain symmetric', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - - expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() - }) -}) - -describe('Masyu adjacent white pearls lookahead', () => { - it('keeps parallel paths for horizontal white pearls when through is impossible', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') - - const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([1, 2], [2, 2])]: 'line', - [lineKey([2, 2], [3, 2])]: 'line', - [lineKey([1, 3], [2, 3])]: 'line', - [lineKey([2, 3], [3, 3])]: 'line', - [lineKey([2, 2], [2, 3])]: 'blank', - }) - expect(result?.affectedCells).toEqual([cellKey(2, 2), cellKey(2, 3)]) - expect(result?.message).toContain('parallel') - }) - - it('keeps one horizontal through-line when parallel is impossible', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') - - const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 2], [2, 3])]: 'line', - [lineKey([2, 1], [2, 2])]: 'line', - [lineKey([2, 3], [2, 4])]: 'line', - [lineKey([2, 2], [3, 2])]: 'blank', - [lineKey([2, 3], [3, 3])]: 'blank', - }) - expect(result?.message).toContain('one straight line') - }) - - it('keeps parallel paths for vertical white pearls when through is impossible', () => { - const puzzle = createMasyuPuzzle(6, 5) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 3, 2, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') - - const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 1], [2, 2])]: 'line', - [lineKey([2, 2], [2, 3])]: 'line', - [lineKey([3, 1], [3, 2])]: 'line', - [lineKey([3, 2], [3, 3])]: 'line', - [lineKey([2, 2], [3, 2])]: 'blank', - }) - }) - - it('keeps one vertical through-line when parallel is impossible', () => { - const puzzle = createMasyuPuzzle(6, 5) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 3, 2, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([3, 1], [3, 2]), 'blank') - - const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 2], [3, 2])]: 'line', - [lineKey([1, 2], [2, 2])]: 'line', - [lineKey([3, 2], [4, 2])]: 'line', - [lineKey([2, 2], [2, 3])]: 'blank', - [lineKey([3, 2], [3, 3])]: 'blank', - }) - }) - - it('does nothing when both adjacent-white patterns remain feasible', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - - expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() - }) - - it('does nothing when both adjacent-white patterns are impossible', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - - expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() - }) - - it('does not force the remaining adjacent-white pattern through a degree-2 cell', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([2, 1], [3, 1]), 'line') - - expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() - }) - - it('rejects the reported adjacent-white horizontal assumption when it creates a single-exit empty cell', () => { - const puzzle = createMasyuPuzzle(10, 10) - addPearl(puzzle, 6, 6, 'black') - addPearl(puzzle, 4, 8, 'white') - addPearl(puzzle, 5, 8, 'white') - markLine(puzzle, lineKey([4, 6], [5, 6]), 'line') - markLine(puzzle, lineKey([5, 6], [6, 6]), 'line') - markLine(puzzle, lineKey([4, 6], [4, 7]), 'line') - markLine(puzzle, lineKey([6, 6], [6, 7]), 'line') - markLine(puzzle, lineKey([6, 7], [6, 8]), 'line') - - const horizontalThroughOverlay = new Map([ - [lineKey([5, 7], [5, 8]), 'line'], - [lineKey([5, 8], [5, 9]), 'line'], - [lineKey([4, 7], [4, 8]), 'line'], - [lineKey([4, 8], [4, 9]), 'line'], - ]) - - expect( - createMasyuLookaheadContext(puzzle).isOverlayLocallyFeasible( - [cellKey(4, 8), cellKey(5, 8)], - horizontalThroughOverlay, - ), - ).toBe(false) - }) -}) - -describe('Masyu empty cell candidate pruning', () => { - it('skips an unconstrained center empty cell with four unknown exits', () => { - const puzzle = createMasyuPuzzle(3, 3) - - expect(createEmptyCellCandidatePruningRule().apply(puzzle)).toBeNull() - }) - - it('forces the only corner continuation from a degree-1 empty cell', () => { - const puzzle = createMasyuPuzzle(2, 2) - const south = lineKey([0, 0], [1, 0]) - const east = lineKey([0, 0], [0, 1]) - markLine(puzzle, south, 'line') - - const result = createEmptyCellCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'line' }) - expect(result?.affectedCells).toEqual([cellKey(0, 0)]) - expect(result?.message).toContain('Empty cell') - }) - - it('rejects candidates that would overflow a touched cell degree', () => { - const puzzle = createMasyuPuzzle(2, 4) - const target = cellKey(0, 1) - const east = lineKey([0, 1], [0, 2]) - const west = lineKey([0, 0], [0, 1]) - const south = lineKey([0, 1], [1, 1]) - markLine(puzzle, west, 'blank') - markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - - const result = createEmptyCellCandidatePruningRule().apply(puzzle) - - expect(result?.affectedCells).toEqual([target]) - expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank' }) - }) - - it('rejects candidates that make a nearby white pearl locally impossible', () => { - const puzzle = createMasyuPuzzle(2, 4) - addPearl(puzzle, 0, 2, 'white') - const westOfEmpty = lineKey([0, 0], [0, 1]) - markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') - - const result = createEmptyCellCandidatePruningRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [westOfEmpty]: 'line', - [lineKey([0, 0], [1, 0])]: 'line', - }) - }) - - it('does not rely on downstream deterministic propagation inside candidates', () => { - const puzzle = createMasyuPuzzle(3, 3) - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'unknown') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'unknown') - - expect(createEmptyCellCandidatePruningRule().apply(puzzle)).toBeNull() - }) -}) - -describe('Masyu shared helper primitives', () => { - it('collects compatible line and tile decisions without overwriting conflicts', () => { - const puzzle = createMasyuPuzzle(3, 3) - const targetLine = lineKey([1, 1], [1, 2]) - const lineDecisions = createMasyuLineDecisionCollector(puzzle) - - expect(lineDecisions.add(targetLine, 'line')).toBe(true) - expect(lineDecisions.add(targetLine, 'line')).toBe(true) - expect(lineDecisions.add(targetLine, 'blank')).toBe(false) - expect(lineDecisions.diffs()).toEqual([ - { kind: 'line', lineKey: targetLine, from: 'unknown', to: 'line' }, - ]) - - const targetTile = tileKey(1, 1) - const tileDecisions = createMasyuTileDecisionCollector(puzzle) - expect(tileDecisions.add(targetTile, 'green')).toBe(true) - expect(tileDecisions.add(targetTile, 'yellow')).toBe(false) - expect(tileDecisions.diffs()).toEqual([ - { kind: 'tile', tileKey: targetTile, fromFill: null, toFill: 'green' }, - ]) - }) - - it('guards line decisions that would overflow endpoint degree', () => { - const puzzle = createMasyuPuzzle(3, 3) - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - const guarded = createMasyuLineDecisionCollector(puzzle, { - guardLineDegree: true, - }) - - expect(guarded.add(lineKey([1, 1], [1, 2]), 'line')).toBe(false) - expect(guarded.hasChanges()).toBe(false) - }) - - it('selects pearl keys and colors in puzzle order', () => { - const puzzle = createMasyuPuzzle(3, 3) - addPearl(puzzle, 0, 0, 'black') - addPearl(puzzle, 1, 1, 'white') - - expect(getMasyuPearlKeys(puzzle)).toEqual([cellKey(0, 0), cellKey(1, 1)]) - expect(getMasyuBlackPearlKeys(puzzle)).toEqual([cellKey(0, 0)]) - expect(getMasyuWhitePearlKeys(puzzle)).toEqual([cellKey(1, 1)]) - expect(getMasyuPearlColor(puzzle, cellKey(1, 1))).toBe('white') - }) - - it('shares line graph vocabulary for premature closures and required sources', () => { - const puzzle = createMasyuPuzzle(4, 4) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([3, 2], [3, 3]), 'line') - const closingLine = lineKey([0, 0], [1, 0]) - - expect(findMasyuPrematureLoopClosingLines(puzzle)).toEqual([closingLine]) - expect( - getMasyuRequiredSources(puzzle, buildMasyuCandidateGraph(puzzle)), - ).toEqual( - new Set([ - cellKey(2, 2), - cellKey(0, 0), - cellKey(0, 1), - cellKey(1, 1), - cellKey(1, 0), - cellKey(3, 2), - cellKey(3, 3), - ]), - ) - }) - - it('builds tile parity from boundary, line, blank, and fixed color anchors', () => { - const puzzle = createMasyuPuzzle(3, 3) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'blank') - puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } - - const parity = buildMasyuTileParityGraph(puzzle) - - expect(parity.getInferredColor(tileKey(0, 1))).toBe('yellow') - expect(parity.getInferredColor(tileKey(1, 1))).toBe('green') - expect(parity.getInferredColor(tileKey(2, 1))).toBe('green') - expect(parity.firstConflict).toBeNull() - }) -}) - -describe('Masyu hard contradiction detection', () => { - it('treats a degree-1 empty cell as closed when every unknown exit would overflow its other endpoint', () => { - const puzzle = createMasyuPuzzle(3, 4) - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([1, 0], [2, 0]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 0]), 'line') - - const reason = findMasyuHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('cell-degree') - expect(reason?.message).toContain('only one line segment') - }) -}) - -describe('Masyu black pearl strong inference', () => { - it('crosses out a black pearl exit whose two-step assumption causes a degree contradiction', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') - const north = lineKey([1, 2], [2, 2]) - - const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'blank' }) - expect(result?.affectedCells).toEqual([cellKey(2, 2)]) - expect(result?.affectedLines).toEqual([north]) - expect(result?.message).toContain('cell-degree contradiction') - }) - - it('uses deterministic downstream rules to find a contradiction', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - const north = lineKey([1, 2], [2, 2]) - const westOfNeighbor = lineKey([1, 1], [1, 2]) - const eastOfNeighbor = lineKey([1, 2], [1, 3]) - const downstreamRule: Rule = { - id: 'test-downstream-degree', - name: 'Test Downstream Degree', - apply: (trial) => { - if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { - return null - } - if ((trial.lines[westOfNeighbor]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'Force a downstream contradiction', - diffs: [ - { - kind: 'line', - lineKey: westOfNeighbor, - from: 'unknown', - to: 'line', - }, - { - kind: 'line', - lineKey: eastOfNeighbor, - from: 'unknown', - to: 'line', - }, - ], - affectedCells: [], - affectedLines: [westOfNeighbor, eastOfNeighbor], - } - }, - } - - const result = createBlackPearlStrongInferenceRule(() => [ - downstreamRule, - ]).apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'blank' }) - expect(result?.message).toContain('after 1 step') - }) - - it('does not copy a solved trial board back into the real puzzle', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - const north = lineKey([1, 2], [2, 2]) - const unrelated = lineKey([4, 3], [4, 4]) - const harmlessRule: Rule = { - id: 'test-harmless-trial-progress', - name: 'Test Harmless Trial Progress', - apply: (trial) => { - if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { - return null - } - if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'Harmless trial-only progress', - diffs: [ - { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, - ], - affectedCells: [], - affectedLines: [unrelated], - } - }, - } - - const result = createBlackPearlStrongInferenceRule(() => [harmlessRule], { - maxTrialSteps: 1, - }).apply(puzzle) - - expect(result).toBeNull() - expect(puzzle.lines[unrelated]?.mark).toBe('unknown') - }) - - it('returns null when the trial budget times out', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - - const result = createBlackPearlStrongInferenceRule(() => [], { - maxMs: -1, - }).apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not overwrite an already decided first exit segment', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - const north = lineKey([1, 2], [2, 2]) - markLine(puzzle, north, 'line') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') - - const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) - - expect(result?.diffs).not.toContainEqual({ - kind: 'line', - lineKey: north, - from: 'line', - to: 'blank', - }) - expect(puzzle.lines[north]?.mark).toBe('line') - }) - - it('registers strong inference after the deterministic Masyu rules', () => { - const rules = masyuPlugin.getRules() - - expect( - rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id), - ).toEqual(deterministicMasyuRules.map((rule) => rule.id)) - expect(rules.at(-3)?.id).toBe('masyu-black-pearl-strong-inference') - expect(rules.at(-2)?.id).toBe('masyu-white-pearl-strong-inference') - expect(rules.at(-1)?.id).toBe('masyu-empty-cell-strong-inference') - }) -}) - -describe('Masyu white pearl strong inference', () => { - it('forces the opposite white pearl axis when one axis causes a tile-color contradiction', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } - puzzle.tiles[tileKey(2, 3)] = { fill: 'yellow' } - const north = lineKey([1, 2], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - const east = lineKey([2, 2], [2, 3]) - const west = lineKey([2, 1], [2, 2]) - - const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'line', - [west]: 'line', - [north]: 'blank', - [south]: 'blank', - }) - expect(result?.affectedCells).toEqual([cellKey(2, 2)]) - expect(result?.affectedLines).toEqual([east, west, north, south]) - expect(result?.message).toContain('tile-color contradiction') - expect(result?.message).toContain('must go horizontal') - }) - - it('uses deterministic downstream rules to find a white-axis contradiction', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - const north = lineKey([1, 2], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - const east = lineKey([2, 2], [2, 3]) - const west = lineKey([2, 1], [2, 2]) - const westOfNeighbor = lineKey([1, 1], [1, 2]) - const eastOfNeighbor = lineKey([1, 2], [1, 3]) - const downstreamRule: Rule = { - id: 'test-white-downstream-degree', - name: 'Test White Downstream Degree', - apply: (trial) => { - if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { - return null - } - if ((trial.lines[westOfNeighbor]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'Force a downstream contradiction', - diffs: [ - { - kind: 'line', - lineKey: westOfNeighbor, - from: 'unknown', - to: 'line', - }, - { - kind: 'line', - lineKey: eastOfNeighbor, - from: 'unknown', - to: 'line', - }, - ], - affectedCells: [], - affectedLines: [westOfNeighbor, eastOfNeighbor], - } - }, - } - - const result = createWhitePearlStrongInferenceRule(() => [ - downstreamRule, - ]).apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'line', - [west]: 'line', - [north]: 'blank', - [south]: 'blank', - }) - expect(result?.message).toContain('after 1 step') - }) - - it('does not copy white trial progress back into the real puzzle', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - const north = lineKey([1, 2], [2, 2]) - const unrelated = lineKey([4, 3], [4, 4]) - const harmlessRule: Rule = { - id: 'test-white-harmless-trial-progress', - name: 'Test White Harmless Trial Progress', - apply: (trial) => { - if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { - return null - } - if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'Harmless trial-only progress', - diffs: [ - { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, - ], - affectedCells: [], - affectedLines: [unrelated], - } - }, - } - - const result = createWhitePearlStrongInferenceRule(() => [harmlessRule], { - maxTrialSteps: 1, - }).apply(puzzle) - - expect(result).toBeNull() - expect(puzzle.lines[unrelated]?.mark).toBe('unknown') - }) - - it('returns null when the white trial budget times out', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - - const result = createWhitePearlStrongInferenceRule(() => [], { - maxMs: -1, - }).apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not overwrite decided lines when forcing the opposite white axis', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } - puzzle.tiles[tileKey(2, 3)] = { fill: 'yellow' } - const east = lineKey([2, 2], [2, 3]) - markLine(puzzle, east, 'blank') - - const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) - - expect(result).toBeNull() - expect(puzzle.lines[east]?.mark).toBe('blank') - }) - - it('tries white pearls with denser nearby pearl context first', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 1, 1, 'white') - puzzle.tiles[tileKey(1, 1)] = { fill: 'yellow' } - puzzle.tiles[tileKey(1, 2)] = { fill: 'yellow' } - addPearl(puzzle, 4, 4, 'white') - addPearl(puzzle, 3, 3, 'black') - addPearl(puzzle, 3, 4, 'white') - puzzle.tiles[tileKey(4, 4)] = { fill: 'yellow' } - puzzle.tiles[tileKey(4, 5)] = { fill: 'yellow' } - const prioritizedEast = lineKey([4, 4], [4, 5]) - const prioritizedWest = lineKey([4, 3], [4, 4]) - const prioritizedNorth = lineKey([3, 4], [4, 4]) - const prioritizedSouth = lineKey([4, 4], [5, 4]) - - const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) - - expect(result?.affectedCells).toEqual([cellKey(4, 4)]) - expectLineDiffs(result?.diffs, { - [prioritizedEast]: 'line', - [prioritizedWest]: 'line', - [prioritizedNorth]: 'blank', - [prioritizedSouth]: 'blank', - }) - }) -}) - -describe('Masyu empty cell strong inference', () => { - it('forces the opposite continuation when one degree-1 branch causes a degree contradiction', () => { - const puzzle = createMasyuPuzzle(3, 4) - const target = cellKey(0, 1) - const west = lineKey([0, 0], [0, 1]) - const east = lineKey([0, 1], [0, 2]) - const south = lineKey([0, 1], [1, 1]) - markLine(puzzle, west, 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') - - const result = createEmptyCellStrongInferenceRule(() => [], { - maxCandidates: 1, - }).apply(puzzle) - - expect(result?.affectedCells).toEqual([target]) - expectLineDiffs(result?.diffs, { - [south]: 'line', - [east]: 'blank', - }) - expect(result?.message).toContain('cell-degree contradiction') - expect(result?.message).toContain('must be the continuation') - }) - - it('crosses out both remaining exits when using both causes a contradiction', () => { - const puzzle = createMasyuPuzzle(2, 3) - const target = cellKey(0, 0) - const east = lineKey([0, 0], [0, 1]) - const south = lineKey([0, 0], [1, 0]) - markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') - - const result = createEmptyCellStrongInferenceRule(() => [], { - maxCandidates: 1, - }).apply(puzzle) - - expect(result?.affectedCells).toEqual([target]) - expectLineDiffs(result?.diffs, { - [east]: 'blank', - [south]: 'blank', - }) - expect(result?.message).toContain('using both remaining exits') - expect(result?.message).toContain('both remaining exits are crossed out') - }) - - it('forces both remaining exits when using neither causes a degree contradiction', () => { - const puzzle = createMasyuPuzzle(2, 3) - const target = cellKey(0, 0) - const east = lineKey([0, 0], [0, 1]) - const south = lineKey([0, 0], [1, 0]) - markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') - - const result = createEmptyCellStrongInferenceRule(() => []).apply(puzzle) - - expect(result?.affectedCells).toEqual([target]) - expectLineDiffs(result?.diffs, { - [east]: 'line', - [south]: 'line', - }) - expect(result?.message).toContain('using neither remaining exit') - expect(result?.message).toContain('both remaining exits must be lines') - }) - - it('uses deterministic downstream rules to find an empty-cell contradiction', () => { - const puzzle = createMasyuPuzzle(3, 4) - const target = cellKey(0, 1) - const west = lineKey([0, 0], [0, 1]) - const east = lineKey([0, 1], [0, 2]) - const south = lineKey([0, 1], [1, 1]) - const eastOfEast = lineKey([0, 2], [0, 3]) - const southOfEast = lineKey([0, 2], [1, 2]) - markLine(puzzle, west, 'line') - const downstreamRule: Rule = { - id: 'test-empty-cell-downstream-degree', - name: 'Test Empty Cell Downstream Degree', - apply: (trial) => { - if ((trial.lines[east]?.mark ?? 'unknown') !== 'line') { - return null - } - if ((trial.lines[eastOfEast]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'Force downstream empty-cell contradiction', - diffs: [ - { - kind: 'line', - lineKey: eastOfEast, - from: 'unknown', - to: 'line', - }, - { - kind: 'line', - lineKey: southOfEast, - from: 'unknown', - to: 'line', - }, - ], - affectedCells: [], - affectedLines: [eastOfEast, southOfEast], - } - }, - } - - const result = createEmptyCellStrongInferenceRule(() => [ - downstreamRule, - ]).apply(puzzle) - - expect(result?.affectedCells).toEqual([target]) - expectLineDiffs(result?.diffs, { - [south]: 'line', - [east]: 'blank', - }) - expect(result?.message).toContain('after 1 step') - }) - - it('does not copy empty-cell trial progress back into the real puzzle', () => { - const puzzle = createMasyuPuzzle(3, 4) - const west = lineKey([0, 0], [0, 1]) - const east = lineKey([0, 1], [0, 2]) - const unrelated = lineKey([2, 2], [2, 3]) - markLine(puzzle, west, 'line') - const harmlessRule: Rule = { - id: 'test-empty-cell-harmless-trial-progress', - name: 'Test Empty Cell Harmless Trial Progress', - apply: (trial) => { - if ((trial.lines[east]?.mark ?? 'unknown') !== 'line') { - return null - } - if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'Harmless empty-cell trial-only progress', - diffs: [ - { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, - ], - affectedCells: [], - affectedLines: [unrelated], - } - }, - } - - const result = createEmptyCellStrongInferenceRule(() => [harmlessRule], { - maxTrialSteps: 1, - }).apply(puzzle) - - expect(result).toBeNull() - expect(puzzle.lines[unrelated]?.mark).toBe('unknown') - }) - - it('returns null when the empty-cell trial budget times out', () => { - const puzzle = createMasyuPuzzle(3, 3) - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') - - const result = createEmptyCellStrongInferenceRule(() => [], { - maxMs: -1, - }).apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not force an opposite empty-cell branch through a degree-2 neighbor', () => { - const puzzle = createMasyuPuzzle(3, 4) - const west = lineKey([0, 0], [0, 1]) - const east = lineKey([0, 1], [0, 2]) - const south = lineKey([0, 1], [1, 1]) - markLine(puzzle, west, 'line') - markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') - markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') - markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') - - const result = createEmptyCellStrongInferenceRule(() => [], { - maxCandidates: 1, - }).apply(puzzle) - - expect(result).toBeNull() - expect(puzzle.lines[east]?.mark).toBe('unknown') - expect(puzzle.lines[south]?.mark).toBe('unknown') - }) -}) - -describe('Masyu pattern rules', () => { - it('Black Facing Consecutive Whites forces away from horizontal consecutive white pearls', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 1, 'black') - addPearl(puzzle, 2, 3, 'white') - addPearl(puzzle, 2, 4, 'white') - const west = lineKey([2, 0], [2, 1]) - - const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [west]: 'line' }) - expect(result?.affectedCells).toEqual([cellKey(2, 1)]) - }) - - it('Black Facing Consecutive Whites forces away from vertical consecutive white pearls', () => { - const puzzle = createMasyuPuzzle(6, 5) - addPearl(puzzle, 3, 2, 'black') - addPearl(puzzle, 1, 2, 'white') - addPearl(puzzle, 0, 2, 'white') - const south = lineKey([3, 2], [4, 2]) - - const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'line' }) - }) - - it('Black Facing Consecutive Whites does not fire with only one distant white pearl', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 1, 'black') - addPearl(puzzle, 2, 3, 'white') - - expect(createBlackFacingConsecutiveWhitesRule().apply(puzzle)).toBeNull() - }) - - it('Black Facing Consecutive Whites allows the gap cell to contain a pearl', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 1, 'black') - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 2, 3, 'white') - addPearl(puzzle, 2, 4, 'white') - const west = lineKey([2, 0], [2, 1]) - - const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [west]: 'line' }) - }) - - it('Black Diagonal White Pinch forces away from white pearls north of a black pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 1, 1, 'white') - addPearl(puzzle, 1, 3, 'white') - const south = lineKey([2, 2], [3, 2]) - - const result = createBlackDiagonalWhitePinchRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'line' }) - }) - - it('Black Diagonal White Pinch forces away from white pearls east of a black pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 1, 3, 'white') - addPearl(puzzle, 3, 3, 'white') - const west = lineKey([2, 1], [2, 2]) - - const result = createBlackDiagonalWhitePinchRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [west]: 'line' }) - }) - - it('Black Diagonal White Pinch forces away from white pearls south of a black pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 3, 1, 'white') - addPearl(puzzle, 3, 3, 'white') - const north = lineKey([1, 2], [2, 2]) - - const result = createBlackDiagonalWhitePinchRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'line' }) - }) - - it('Black Diagonal White Pinch forces away from white pearls west of a black pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 1, 1, 'white') - addPearl(puzzle, 3, 1, 'white') - const east = lineKey([2, 2], [2, 3]) - - const result = createBlackDiagonalWhitePinchRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'line' }) - }) - - it('Black Diagonal White Pinch does not fire when one diagonal white pearl is missing', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 1, 3, 'white') - - expect(createBlackDiagonalWhitePinchRule().apply(puzzle)).toBeNull() - }) - - it('Black Diagonal White Pinch does not overwrite a blank forced line', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 1, 3, 'white') - addPearl(puzzle, 3, 3, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - - expect(createBlackDiagonalWhitePinchRule().apply(puzzle)).toBeNull() - }) - - it('Consecutive White Pearls Straight forces vertical pass-through for three horizontal white pearls', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 1, 'white') - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - - const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([1, 1], [2, 1])]: 'line', - [lineKey([2, 1], [3, 1])]: 'line', - [lineKey([1, 2], [2, 2])]: 'line', - [lineKey([2, 2], [3, 2])]: 'line', - [lineKey([1, 3], [2, 3])]: 'line', - [lineKey([2, 3], [3, 3])]: 'line', - }) - }) - - it('Consecutive White Pearls Straight forces horizontal pass-through for three vertical white pearls', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 1, 2, 'white') - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 3, 2, 'white') - - const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([1, 1], [1, 2])]: 'line', - [lineKey([1, 2], [1, 3])]: 'line', - [lineKey([2, 1], [2, 2])]: 'line', - [lineKey([2, 2], [2, 3])]: 'line', - [lineKey([3, 1], [3, 2])]: 'line', - [lineKey([3, 2], [3, 3])]: 'line', - }) - }) - - it('Consecutive White Pearls Straight covers every pearl in a longer run', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 3, 1, 'white') - addPearl(puzzle, 3, 2, 'white') - addPearl(puzzle, 3, 3, 'white') - addPearl(puzzle, 3, 4, 'white') - - const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 1], [3, 1])]: 'line', - [lineKey([3, 1], [4, 1])]: 'line', - [lineKey([2, 2], [3, 2])]: 'line', - [lineKey([3, 2], [4, 2])]: 'line', - [lineKey([2, 3], [3, 3])]: 'line', - [lineKey([3, 3], [4, 3])]: 'line', - [lineKey([2, 4], [3, 4])]: 'line', - [lineKey([3, 4], [4, 4])]: 'line', - }) - }) - - it('Consecutive White Pearls Straight does not fire for only two consecutive white pearls', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 1, 'white') - addPearl(puzzle, 2, 2, 'white') - - expect(createConsecutiveWhitePearlsStraightRule().apply(puzzle)).toBeNull() - }) - - it('Consecutive White Pearls Straight does not overwrite a blank target line', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 1, 'white') - addPearl(puzzle, 2, 2, 'white') - addPearl(puzzle, 2, 3, 'white') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') - - const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([2, 1], [3, 1])]: 'line', - [lineKey([1, 2], [2, 2])]: 'line', - [lineKey([2, 2], [3, 2])]: 'line', - [lineKey([1, 3], [2, 3])]: 'line', - [lineKey([2, 3], [3, 3])]: 'line', - }) - }) - - it('White Corridor forces the two inner white pearls through the L-shaped corridor', () => { - const puzzle = createMasyuPuzzle(8, 8) - addPearl(puzzle, 4, 5, 'white') - addPearl(puzzle, 5, 4, 'white') - markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') - markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') - markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - - const result = createWhiteCorridorRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([4, 4], [4, 5])]: 'line', - [lineKey([4, 5], [4, 6])]: 'line', - [lineKey([4, 4], [5, 4])]: 'line', - [lineKey([5, 4], [6, 4])]: 'line', - }) - expect(result?.affectedCells).toEqual([ - cellKey(3, 3), - cellKey(4, 5), - cellKey(5, 4), - ]) - }) - - it('White Corridor works after rotation', () => { - const puzzle = createMasyuPuzzle(8, 8) - addPearl(puzzle, 5, 2, 'white') - addPearl(puzzle, 4, 1, 'white') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') - markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') - markLine(puzzle, lineKey([3, 3], [3, 2]), 'line') - markLine(puzzle, lineKey([3, 2], [3, 1]), 'line') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - - const result = createWhiteCorridorRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [lineKey([4, 2], [5, 2])]: 'line', - [lineKey([5, 2], [6, 2])]: 'line', - [lineKey([4, 1], [4, 2])]: 'line', - [lineKey([4, 0], [4, 1])]: 'line', - }) - }) - - it('White Corridor does not fire before there are multiple known line components', () => { - const puzzle = createMasyuPuzzle(8, 8) - addPearl(puzzle, 4, 5, 'white') - addPearl(puzzle, 5, 4, 'white') - markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') - markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') - markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') - - expect(createWhiteCorridorRule().apply(puzzle)).toBeNull() - }) - - it('White Corridor does not overwrite a blank forced line', () => { - const puzzle = createMasyuPuzzle(8, 8) - addPearl(puzzle, 4, 5, 'white') - addPearl(puzzle, 5, 4, 'white') - markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') - markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') - markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') - markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') - markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') - markLine(puzzle, lineKey([4, 4], [4, 5]), 'blank') - - expect(createWhiteCorridorRule().apply(puzzle)).toBeNull() - }) - - it('Double Black Squeeze blanks the opposite vertical exit between horizontal black pearls', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 2, 4, 'black') - markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') - const south = lineKey([2, 3], [3, 3]) - - const result = createDoubleBlackSqueezeRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'blank' }) - expect(result?.affectedCells).toEqual([cellKey(2, 3)]) - }) - - it('Double Black Squeeze works in the reverse vertical direction between horizontal black pearls', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 2, 4, 'black') - markLine(puzzle, lineKey([2, 3], [3, 3]), 'blank') - const north = lineKey([1, 3], [2, 3]) - - const result = createDoubleBlackSqueezeRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'blank' }) - }) - - it('Double Black Squeeze blanks the opposite horizontal exit between vertical black pearls', () => { - const puzzle = createMasyuPuzzle(6, 6) - addPearl(puzzle, 2, 3, 'black') - addPearl(puzzle, 4, 3, 'black') - markLine(puzzle, lineKey([3, 2], [3, 3]), 'blank') - const east = lineKey([3, 3], [3, 4]) - - const result = createDoubleBlackSqueezeRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'blank' }) - }) - - it('Double Black Squeeze treats the bottom edge as a crossed-out perpendicular exit', () => { - const puzzle = createMasyuPuzzle(10, 10) - addPearl(puzzle, 9, 5, 'black') - addPearl(puzzle, 9, 7, 'black') - const north = lineKey([8, 6], [9, 6]) - - const result = createDoubleBlackSqueezeRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'blank' }) - expect(result?.affectedCells).toEqual([cellKey(9, 6)]) - }) - - it('Double Black Squeeze treats the top edge as a crossed-out perpendicular exit', () => { - const puzzle = createMasyuPuzzle(10, 10) - addPearl(puzzle, 0, 2, 'black') - addPearl(puzzle, 0, 4, 'black') - const south = lineKey([0, 3], [1, 3]) - - const result = createDoubleBlackSqueezeRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'blank' }) - expect(result?.affectedCells).toEqual([cellKey(0, 3)]) - }) - - it('Double Black Squeeze treats side edges as crossed-out perpendicular exits', () => { - const puzzle = createMasyuPuzzle(10, 10) - addPearl(puzzle, 2, 0, 'black') - addPearl(puzzle, 4, 0, 'black') - addPearl(puzzle, 5, 9, 'black') - addPearl(puzzle, 7, 9, 'black') - const rightFromLeftEdge = lineKey([3, 0], [3, 1]) - const leftFromRightEdge = lineKey([6, 8], [6, 9]) - - const result = createDoubleBlackSqueezeRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [rightFromLeftEdge]: 'blank', - [leftFromRightEdge]: 'blank', - }) - expect(result?.affectedCells).toEqual([cellKey(3, 0), cellKey(6, 9)]) - }) - - it('Double Black Squeeze does not overwrite a border perpendicular exit that is already a line', () => { - const puzzle = createMasyuPuzzle(10, 10) - addPearl(puzzle, 9, 5, 'black') - addPearl(puzzle, 9, 7, 'black') - markLine(puzzle, lineKey([8, 6], [9, 6]), 'line') - - expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() - }) - - it('Double Black Squeeze handles the supplied bottom-edge puzzle without strong inference', () => { - let puzzle = masyuPlugin.parse( - 'https://puzz.link/p?mashu/10/10/0000000000000000000000000300000260', - ) - const rules = deterministicMasyuRules - const expectedLines = [ - lineKey([7, 5], [8, 5]), - lineKey([8, 5], [9, 5]), - lineKey([7, 7], [8, 7]), - lineKey([8, 7], [9, 7]), - ] - const squeezed = lineKey([8, 6], [9, 6]) - let sawDoubleBlackSqueeze = false - - for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { - const result = runNextRule(puzzle, rules, stepNumber) - if (!result.step) { - break - } - sawDoubleBlackSqueeze ||= result.step.ruleName === 'Double Black Squeeze' - puzzle = result.nextPuzzle - } - - expect(sawDoubleBlackSqueeze).toBe(true) - expect(puzzle.lines[squeezed]?.mark).toBe('blank') - for (const key of expectedLines) { - expect(puzzle.lines[key]?.mark).toBe('line') - } - }) - - it('Double Black Squeeze does not fire unless both opposite cells are black pearls', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 2, 4, 'white') - markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') - - expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() - }) - - it('Double Black Squeeze ignores adjacent black pearls that do not enclose a middle cell', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 2, 3, 'black') - markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') - - expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() - }) - - it('Double Black Squeeze does not overwrite an opposite exit that is already a line', () => { - const puzzle = createMasyuPuzzle(5, 6) - addPearl(puzzle, 2, 2, 'black') - addPearl(puzzle, 2, 4, 'black') - markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') - markLine(puzzle, lineKey([2, 3], [3, 3]), 'line') - - expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() - }) -}) - -describe('Masyu Cell Exit Completion', () => { - it('forces the opposite white pearl exit when only the straight continuation remains', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'line' }) - }) - - it('forces the only available straight pair on a white pearl', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - const north = lineKey([1, 2], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'line', [south]: 'line' }) - }) - - it('blanks remaining white pearl exits after a straight pair is complete', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') - const west = lineKey([2, 1], [2, 2]) - const east = lineKey([2, 2], [2, 3]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [west]: 'blank', [east]: 'blank' }) - }) - - it('does not force a white pearl when only a turning pair remains', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - - expect(createCellExitCompletionRule().apply(puzzle)).toBeNull() - }) - - it('forces a black pearl turn continuation without extending exits', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'line' }) - }) - - it('forces the only available turning pair on a black pearl without extending it', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - const west = lineKey([2, 1], [2, 2]) - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [west]: 'line', - [south]: 'line', - }) - }) - - it('blanks remaining exits after a completed black pearl turn', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') - const east = lineKey([2, 2], [2, 3]) - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [east]: 'blank', - [south]: 'blank', - }) - }) - - it('does not force a black pearl when only a straight pair remains', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') - - expect(createCellExitCompletionRule().apply(puzzle)).toBeNull() - }) - - it('does not inspect black pearl extensions', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') - markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') - markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [south]: 'line' }) - }) -}) - -describe('Masyu Cell Exit Completion', () => { - it('connects the only remaining candidate when a regular cell has one line', () => { - const puzzle = createMasyuPuzzle(3, 3) - const west = lineKey([1, 1], [1, 0]) - const east = lineKey([1, 1], [1, 2]) - markLine(puzzle, west, 'line') - markLine(puzzle, lineKey([1, 1], [0, 1]), 'blank') - markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'line' }) - }) - - it('blanks every remaining candidate when a cell already has degree 2', () => { - const puzzle = createMasyuPuzzle(3, 3) - const north = lineKey([1, 1], [0, 1]) - const south = lineKey([1, 1], [2, 1]) - markLine(puzzle, lineKey([1, 1], [1, 0]), 'line') - markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) - }) - - it('blanks a single remaining candidate on a regular dead-end cell', () => { - const puzzle = createMasyuPuzzle(1, 2) - const onlyLine = lineKey([0, 0], [0, 1]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [onlyLine]: 'blank' }) - }) - - it('uses white pearl completion when traversing all cells', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'white') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - const north = lineKey([1, 2], [2, 2]) - const east = lineKey([2, 2], [2, 3]) - const south = lineKey([2, 2], [3, 2]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { - [north]: 'blank', - [east]: 'line', - [south]: 'blank', - }) - }) - - it('uses black pearl completion when traversing all cells', () => { - const puzzle = createMasyuPuzzle(5, 5) - addPearl(puzzle, 2, 2, 'black') - markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') - const east = lineKey([2, 2], [2, 3]) - - const result = createCellExitCompletionRule().apply(puzzle) - - expectLineDiffs(result?.diffs, { [east]: 'blank' }) - }) -}) diff --git a/src/domain/rules/masyu/rules.ts b/src/domain/rules/masyu/rules.ts index c0fde5e..df8c452 100644 --- a/src/domain/rules/masyu/rules.ts +++ b/src/domain/rules/masyu/rules.ts @@ -13,7 +13,7 @@ import { } from './rules/color' import { createMasyuTileConnectivityCutColoringRule } from './rules/connectivity' import { createCellExitCompletionRule } from './rules/completion' -import { createEmptyCellStrongInferenceRule } from './rules/emptyCellStrongInference' +import { createLineComponentEndpointStrongInferenceRule } from './rules/lineComponentEndpointStrongInference' import { createMasyuEmptyCellPrematureLoopRule, createPreventPrematureLoopRule, @@ -52,6 +52,6 @@ export const deterministicMasyuRules: Rule[] = [ export const masyuRules: Rule[] = [ ...deterministicMasyuRules, createBlackPearlStrongInferenceRule(() => deterministicMasyuRules), + createLineComponentEndpointStrongInferenceRule(() => deterministicMasyuRules), createWhitePearlStrongInferenceRule(() => deterministicMasyuRules), - createEmptyCellStrongInferenceRule(() => deterministicMasyuRules), ] diff --git a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts index 55782e1..3b725a8 100644 --- a/src/domain/rules/masyu/rules/blackPearlStrongInference.ts +++ b/src/domain/rules/masyu/rules/blackPearlStrongInference.ts @@ -16,6 +16,7 @@ import { } from './shared' import { buildMasyuStrongBranch, + buildMasyuInferenceDetails, deriveMasyuStrongProbeBudgets, describeMasyuStrongTrialResult, immediateMasyuStrongContradictionResult, @@ -134,22 +135,30 @@ export const createBlackPearlStrongInferenceRule = ( continue } + const diffs: RuleApplication['diffs'] = [ + { + kind: 'line', + lineKey: candidate.firstLine, + from: 'unknown', + to: 'blank', + }, + ] return { message: `Black Pearl Strong Inference: assuming ${formatMasyuCellKeyLabel(candidate.pearlKey)} exits ${candidate.direction} ` + `(${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult(result)}, so ${formatMasyuLineLabel( candidate.firstLine, )} is crossed out.`, - diffs: [ - { - kind: 'line', - lineKey: candidate.firstLine, - from: 'unknown', - to: 'blank', - }, - ], + diffs, affectedCells: [candidate.pearlKey], affectedLines: [candidate.firstLine], + inferenceDetails: buildMasyuInferenceDetails( + puzzle, + `Assume ${formatMasyuCellKeyLabel(candidate.pearlKey)} exits ${candidate.direction}`, + branch, + result, + diffs, + ), } } } diff --git a/src/domain/rules/masyu/rules/emptyCellStrongInference.ts b/src/domain/rules/masyu/rules/emptyCellStrongInference.ts deleted file mode 100644 index 238efe2..0000000 --- a/src/domain/rules/masyu/rules/emptyCellStrongInference.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { cellKey } from '../../../ir/keys' -import type { PuzzleIR } from '../../../ir/types' -import type { Rule, RuleApplication } from '../../types' -import { createMasyuLineDecisionCollector } from './decisionCollector' -import { - formatMasyuCellKeyLabel, - formatMasyuLineLabel, - getMasyuIncidentDirectionalLines, - MASYU_DIRECTIONS, - type MasyuDirectionalLine, -} from './shared' -import { - buildMasyuStrongBranch, - deriveMasyuStrongProbeBudgets, - describeMasyuStrongTrialResult, - immediateMasyuStrongContradictionResult, - STRONG_MAX_CANDIDATES, - STRONG_MAX_MS, - STRONG_MAX_TRIAL_STEPS, - type MasyuLineAssumption, - type MasyuStrongInferenceOptions, -} from './strongInference' -import { runMasyuTrialUntilFixpoint } from './trial' - -type EmptyCellStrongCandidate = { - cell: string - mode: 'continuation' | 'degree-zero' - unknowns: [MasyuDirectionalLine, MasyuDirectionalLine] -} - -type EmptyCellStrongBranch = { - label: string - assumptions: MasyuLineAssumption[] - forced: MasyuLineAssumption[] -} - -const collectEmptyCellStrongCandidates = ( - puzzle: PuzzleIR, - maxCandidates: number, -): EmptyCellStrongCandidate[] => { - const candidates: EmptyCellStrongCandidate[] = [] - for (let row = 0; row < puzzle.rows; row += 1) { - for (let col = 0; col < puzzle.cols; col += 1) { - const key = cellKey(row, col) - if (puzzle.cells[key]?.clue?.kind === 'pearl') { - continue - } - - const incidentByDirection = getMasyuIncidentDirectionalLines(puzzle, key) - const incident = MASYU_DIRECTIONS.flatMap((direction) => { - const item = incidentByDirection[direction] - return item ? [item] : [] - }) - const lineCount = incident.filter((item) => item.mark === 'line').length - const unknowns = incident.filter((item) => item.mark === 'unknown') - if (unknowns.length !== 2 || (lineCount !== 0 && lineCount !== 1)) { - continue - } - - candidates.push({ - cell: key, - mode: lineCount === 1 ? 'continuation' : 'degree-zero', - unknowns: [unknowns[0], unknowns[1]], - }) - if (candidates.length >= maxCandidates) { - return candidates - } - } - } - return candidates -} - -const buildBranches = ( - candidate: EmptyCellStrongCandidate, -): EmptyCellStrongBranch[] => { - const [first, second] = candidate.unknowns - if (candidate.mode === 'continuation') { - return [ - { - label: `${formatMasyuLineLabel(first.lineKey)} as the continuation`, - assumptions: [ - [first.lineKey, 'line'], - [second.lineKey, 'blank'], - ], - forced: [ - [second.lineKey, 'line'], - [first.lineKey, 'blank'], - ], - }, - { - label: `${formatMasyuLineLabel(second.lineKey)} as the continuation`, - assumptions: [ - [second.lineKey, 'line'], - [first.lineKey, 'blank'], - ], - forced: [ - [first.lineKey, 'line'], - [second.lineKey, 'blank'], - ], - }, - ] - } - - return [ - { - label: 'using both remaining exits', - assumptions: [ - [first.lineKey, 'line'], - [second.lineKey, 'line'], - ], - forced: [ - [first.lineKey, 'blank'], - [second.lineKey, 'blank'], - ], - }, - { - label: 'using neither remaining exit', - assumptions: [ - [first.lineKey, 'blank'], - [second.lineKey, 'blank'], - ], - forced: [ - [first.lineKey, 'line'], - [second.lineKey, 'line'], - ], - }, - ] -} - -const collectForcedDiffs = ( - puzzle: PuzzleIR, - forced: MasyuLineAssumption[], -): ReturnType['diffs']> | null => { - const decisions = createMasyuLineDecisionCollector(puzzle, { - guardLineDegree: true, - }) - for (const [lineKeyValue, mark] of forced) { - if (!decisions.add(lineKeyValue, mark)) { - return null - } - } - return decisions.hasChanges() ? decisions.diffs() : null -} - -const describeForced = (forced: MasyuLineAssumption[]): string => { - const lineCount = forced.filter(([, mark]) => mark === 'line').length - const blankCount = forced.filter(([, mark]) => mark === 'blank').length - if (lineCount === 2) { - return 'both remaining exits must be lines' - } - if (blankCount === 2) { - return 'both remaining exits are crossed out' - } - const line = forced.find(([, mark]) => mark === 'line')?.[0] - return line - ? `${formatMasyuLineLabel(line)} must be the continuation` - : forced - .map( - ([lineKeyValue, mark]) => - `${formatMasyuLineLabel(lineKeyValue)} ${mark}`, - ) - .join(', ') -} - -export const createEmptyCellStrongInferenceRule = ( - getDeterministicRules: () => Rule[], - options: MasyuStrongInferenceOptions = {}, -): Rule => ({ - id: 'masyu-empty-cell-strong-inference', - name: 'Empty Cell Strong Inference', - apply: (puzzle: PuzzleIR): RuleApplication | null => { - const deterministicRules = getDeterministicRules() - const candidates = collectEmptyCellStrongCandidates( - puzzle, - options.maxCandidates ?? STRONG_MAX_CANDIDATES, - ) - if (candidates.length === 0) { - return null - } - - const deadlineMs = Date.now() + (options.maxMs ?? STRONG_MAX_MS) - const budgets = deriveMasyuStrongProbeBudgets( - options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, - ) - - for (const budget of budgets) { - for (const candidate of candidates) { - if (Date.now() > deadlineMs) { - return null - } - - for (const trialBranch of buildBranches(candidate)) { - const branch = buildMasyuStrongBranch( - puzzle, - trialBranch.assumptions, - ) - const result = branch.setupOk - ? runMasyuTrialUntilFixpoint( - branch.puzzle, - deterministicRules, - budget, - deadlineMs, - ) - : immediateMasyuStrongContradictionResult(branch.puzzle) - if (result.timedOut) { - return null - } - if (!result.contradiction) { - continue - } - - const diffs = collectForcedDiffs(puzzle, trialBranch.forced) - if (!diffs || diffs.length === 0) { - continue - } - const firstLine = diffs[0]?.lineKey - return { - message: - `Empty Cell Strong Inference: assuming ${formatMasyuCellKeyLabel( - candidate.cell, - )} is ${trialBranch.label} (${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult( - result, - )}, so ${describeForced(trialBranch.forced)} through ${formatMasyuLineLabel( - firstLine, - )}${diffs.length > 1 ? ` (${diffs.length} total)` : ''}.`, - diffs, - affectedCells: [candidate.cell], - affectedLines: diffs.map((diff) => diff.lineKey), - } - } - } - } - - return null - }, -}) diff --git a/src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts b/src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts new file mode 100644 index 0000000..5ca74de --- /dev/null +++ b/src/domain/rules/masyu/rules/lineComponentEndpointStrongInference.ts @@ -0,0 +1,163 @@ +import { parseCellKey } from '../../../ir/keys' +import type { PuzzleIR } from '../../../ir/types' +import type { Rule, RuleApplication } from '../../types' +import { getMasyuOpenLineComponents } from './lineGraph' +import { + formatMasyuCellKeyLabel, + formatMasyuLineLabel, + getMasyuIncidentDirectionalLines, + MASYU_DIRECTIONS, + type MasyuDirectionalLine, +} from './shared' +import { + buildMasyuInferenceDetails, + buildMasyuStrongBranch, + deriveMasyuStrongProbeBudgets, + describeMasyuStrongTrialResult, + immediateMasyuStrongContradictionResult, + STRONG_MAX_CANDIDATES, + STRONG_MAX_MS, + STRONG_MAX_TRIAL_STEPS, + type MasyuLineAssumption, + type MasyuStrongInferenceOptions, +} from './strongInference' +import { runMasyuTrialUntilFixpoint } from './trial' + +const ENDPOINT_STRONG_MAX_CANDIDATES = 60 +const ENDPOINT_STRONG_MAX_MS = 3000 + +type LineComponentEndpointCandidate = { + endpointKey: string + endpointOrder: number + componentEdgeCount: number + unknowns: MasyuDirectionalLine[] + assumed: MasyuDirectionalLine +} + +const collectLineComponentEndpointCandidates = ( + puzzle: PuzzleIR, + maxCandidates: number, +): LineComponentEndpointCandidate[] => + getMasyuOpenLineComponents(puzzle) + .flatMap((component) => + component.endpointKeys.flatMap((endpointKey) => { + const [row, col] = parseCellKey(endpointKey) + const unknowns = MASYU_DIRECTIONS.flatMap((direction) => { + const line = getMasyuIncidentDirectionalLines(puzzle, endpointKey)[ + direction + ] + return line?.mark === 'unknown' ? [line] : [] + }) + if (unknowns.length < 2) { + return [] + } + return unknowns.map((assumed) => ({ + endpointKey, + endpointOrder: row * puzzle.cols + col, + componentEdgeCount: component.edgeCount, + unknowns, + assumed, + })) + }), + ) + .sort( + (left, right) => + left.unknowns.length - right.unknowns.length || + right.componentEdgeCount - left.componentEdgeCount || + left.endpointOrder - right.endpointOrder || + MASYU_DIRECTIONS.indexOf(left.assumed.direction) - + MASYU_DIRECTIONS.indexOf(right.assumed.direction), + ) + .slice(0, maxCandidates) + +const buildCandidateAssumptions = ( + candidate: LineComponentEndpointCandidate, +): MasyuLineAssumption[] => [ + [candidate.assumed.lineKey, 'line'], + ...candidate.unknowns + .filter((line) => line.lineKey !== candidate.assumed.lineKey) + .map((line): MasyuLineAssumption => [line.lineKey, 'blank']), +] + +export const createLineComponentEndpointStrongInferenceRule = ( + getDeterministicRules: () => Rule[], + options: MasyuStrongInferenceOptions = {}, +): Rule => ({ + id: 'masyu-line-component-endpoint-strong-inference', + name: 'Masyu Line Component Endpoint Strong Inference', + apply: (puzzle: PuzzleIR): RuleApplication | null => { + const candidates = collectLineComponentEndpointCandidates( + puzzle, + options.maxCandidates ?? + Math.min(ENDPOINT_STRONG_MAX_CANDIDATES, STRONG_MAX_CANDIDATES), + ) + if (candidates.length === 0) { + return null + } + + const deterministicRules = getDeterministicRules() + const deadlineMs = + Date.now() + + (options.maxMs ?? Math.min(ENDPOINT_STRONG_MAX_MS, STRONG_MAX_MS)) + const budgets = deriveMasyuStrongProbeBudgets( + options.maxTrialSteps ?? STRONG_MAX_TRIAL_STEPS, + ) + + for (const budget of budgets) { + for (const candidate of candidates) { + if (Date.now() > deadlineMs) { + return null + } + const branch = buildMasyuStrongBranch( + puzzle, + buildCandidateAssumptions(candidate), + ) + const result = branch.setupOk + ? runMasyuTrialUntilFixpoint( + branch.puzzle, + deterministicRules, + budget, + deadlineMs, + ) + : immediateMasyuStrongContradictionResult(branch.puzzle) + if (result.timedOut) { + return null + } + if (!result.contradiction) { + continue + } + + const diffs: RuleApplication['diffs'] = [ + { + kind: 'line', + lineKey: candidate.assumed.lineKey, + from: 'unknown', + to: 'blank', + }, + ] + return { + message: + `Masyu Line Component Endpoint Strong Inference: assuming the ${candidate.componentEdgeCount}-segment component at ${formatMasyuCellKeyLabel( + candidate.endpointKey, + )} continues ${candidate.assumed.direction} among ${candidate.unknowns.length} candidate directions (${branch.setupDescription}) leads to ${describeMasyuStrongTrialResult( + result, + )}, so ${formatMasyuLineLabel(candidate.assumed.lineKey)} is crossed out.`, + diffs, + affectedCells: [candidate.endpointKey], + affectedLines: [candidate.assumed.lineKey], + inferenceDetails: buildMasyuInferenceDetails( + puzzle, + `Assume the ${candidate.componentEdgeCount}-segment component at ${formatMasyuCellKeyLabel( + candidate.endpointKey, + )} continues ${candidate.assumed.direction}`, + branch, + result, + diffs, + ), + } + } + } + + return null + }, +}) diff --git a/src/domain/rules/masyu/rules/lineGraph.ts b/src/domain/rules/masyu/rules/lineGraph.ts index 31a4a5d..72e32c1 100644 --- a/src/domain/rules/masyu/rules/lineGraph.ts +++ b/src/domain/rules/masyu/rules/lineGraph.ts @@ -24,6 +24,10 @@ export type MasyuKnownLineComponent = { vertices: Set } +export type MasyuOpenLineComponent = MasyuKnownLineComponent & { + endpointKeys: [string, string] +} + const getOverlayLineMark = ( puzzle: PuzzleIR, overlay: MasyuLineOverlay, @@ -126,6 +130,49 @@ export const getMasyuKnownLineComponents = ( return [...components.values()] } +export const getMasyuOpenLineComponents = ( + puzzle: PuzzleIR, + overlay: MasyuLineOverlay = new Map(), +): MasyuOpenLineComponent[] => { + const degree = new Map() + const { toCellIndex } = buildMasyuLineUnion(puzzle, overlay) + + for (const lineKeyValue of Object.keys(puzzle.lines)) { + if (getOverlayLineMark(puzzle, overlay, lineKeyValue) !== 'line') { + continue + } + const [left, right] = parseLineKey(lineKeyValue) + const leftIndex = toCellIndex(left[0], left[1]) + const rightIndex = toCellIndex(right[0], right[1]) + degree.set(leftIndex, (degree.get(leftIndex) ?? 0) + 1) + degree.set(rightIndex, (degree.get(rightIndex) ?? 0) + 1) + } + + return getMasyuKnownLineComponents(puzzle, overlay).flatMap((component) => { + if (component.edgeCount !== component.vertices.size - 1) { + return [] + } + const endpoints = [...component.vertices].filter( + (vertex) => degree.get(vertex) === 1, + ) + if ( + endpoints.length !== 2 || + [...component.vertices].some((vertex) => { + const vertexDegree = degree.get(vertex) ?? 0 + return vertexDegree !== 1 && vertexDegree !== 2 + }) + ) { + return [] + } + const endpointKeys = endpoints + .sort((left, right) => left - right) + .map((vertex) => + cellKey(Math.floor(vertex / puzzle.cols), vertex % puzzle.cols), + ) as [string, string] + return [{ ...component, endpointKeys }] + }) +} + export const findMasyuPrematureLoopClosingLines = ( puzzle: PuzzleIR, overlay: MasyuLineOverlay = new Map(), diff --git a/src/domain/rules/masyu/rules/strongInference.ts b/src/domain/rules/masyu/rules/strongInference.ts index 5b0a1f2..e7c9d3f 100644 --- a/src/domain/rules/masyu/rules/strongInference.ts +++ b/src/domain/rules/masyu/rules/strongInference.ts @@ -1,5 +1,6 @@ import { clonePuzzle } from '../../../ir/normalize' import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { InferenceDetails, RuleApplication } from '../../types' import { applyMasyuLineAssumption, type MasyuTrialResult, @@ -16,13 +17,14 @@ export type MasyuStrongBranch = { puzzle: PuzzleIR setupOk: boolean setupDescription: string + assumptionDiffs: RuleApplication['diffs'] } export type MasyuLineAssumption = [lineKey: string, mark: LineMark] export const STRONG_MAX_CANDIDATES = 300 export const STRONG_MAX_TRIAL_STEPS = 100 -export const STRONG_MAX_MS = 15000 +export const STRONG_MAX_MS = 30000 export const deriveMasyuStrongProbeBudgets = ( maxTrialSteps: number, @@ -49,7 +51,17 @@ export const buildMasyuStrongBranch = ( ) .join(', ') - return { puzzle: branch, setupOk, setupDescription } + return { + puzzle: branch, + setupOk, + setupDescription, + assumptionDiffs: assumptions.map(([lineKeyValue, mark]) => ({ + kind: 'line', + lineKey: lineKeyValue, + from: puzzle.lines[lineKeyValue]?.mark ?? 'unknown', + to: mark, + })), + } } export const immediateMasyuStrongContradictionResult = ( @@ -65,9 +77,58 @@ export const immediateMasyuStrongContradictionResult = ( kind: 'line-assumption', message: 'line-assumption contradiction: this assumption conflicts with an already decided Masyu line', + lines: [], }, + traceSteps: [], }) +export const buildMasyuInferenceDetails = ( + puzzle: PuzzleIR, + branchLabel: string, + branch: MasyuStrongBranch, + result: MasyuTrialResult, + conclusionDiffs: RuleApplication['diffs'], +): InferenceDetails => { + const contradiction = + result.contradictionReason?.kind === 'line-assumption' + ? { + ...result.contradictionReason, + lines: branch.assumptionDiffs.flatMap((diff) => + diff.kind === 'line' ? [diff.lineKey] : [], + ), + } + : result.contradictionReason + return { + kind: 'masyu-strong', + conclusion: 'opposite-branch', + basePuzzle: clonePuzzle(puzzle), + defaultBranchId: 'assumption', + branches: [ + { + id: 'assumption', + label: branchLabel, + role: 'trial', + initialDiffs: branch.assumptionDiffs, + status: result.contradiction + ? 'contradiction' + : result.exhausted + ? 'exhausted' + : 'unresolved', + traceSteps: result.traceSteps, + contradiction, + }, + { + id: 'conclusion', + label: 'Forced conclusion', + role: 'forced-conclusion', + initialDiffs: conclusionDiffs, + status: 'forced', + traceSteps: [], + }, + ], + } +} + export const describeMasyuStrongTrialResult = ( result: MasyuTrialResult, ): string => { diff --git a/src/domain/rules/masyu/rules/trial.ts b/src/domain/rules/masyu/rules/trial.ts index 2c0dce6..bc7ef83 100644 --- a/src/domain/rules/masyu/rules/trial.ts +++ b/src/domain/rules/masyu/rules/trial.ts @@ -1,7 +1,7 @@ import { cellKey, parseLineKey } from '../../../ir/keys' import type { LineMark, PuzzleIR } from '../../../ir/types' import { runNextRule } from '../../engine' -import type { Rule } from '../../types' +import type { InferenceContradiction, Rule, TrialTraceStep } from '../../types' import { getMasyuKnownLineComponents } from './lineGraph' import { buildMasyuTileParityGraph } from './tileParity' import { @@ -17,7 +17,7 @@ import { type MasyuDirection, } from './shared' -export type MasyuTrialContradictionReason = { +export type MasyuTrialContradictionReason = InferenceContradiction & { kind: | 'cell-degree' | 'pearl-shape' @@ -35,6 +35,7 @@ export type MasyuTrialResult = { stepsRun: number elapsedMs: number contradictionReason?: MasyuTrialContradictionReason + traceSteps: TrialTraceStep[] } export const applyMasyuLineAssumption = ( @@ -90,6 +91,7 @@ const detectCellDegreeContradiction = ( return { kind: 'cell-degree', message: `cell-degree contradiction at ${formatMasyuCellKeyLabel(key)}: ${lineCount} line segments meet there`, + cells: [key], } } const addableUnknownCount = @@ -105,6 +107,7 @@ const detectCellDegreeContradiction = ( return { kind: 'cell-degree', message: `cell-degree contradiction at ${formatMasyuCellKeyLabel(key)}: a closed cell has only one line segment`, + cells: [key], } } } @@ -255,6 +258,7 @@ const detectPearlContradiction = ( return { kind: 'pearl-shape', message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: a black pearl must turn and continue straight after both exits`, + cells: [key], } } if ( @@ -264,6 +268,7 @@ const detectPearlContradiction = ( return { kind: 'pearl-shape', message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: a white pearl must go straight through`, + cells: [key], } } } @@ -276,6 +281,7 @@ const detectPearlContradiction = ( return { kind: 'pearl-shape', message: `pearl-shape contradiction at ${formatMasyuCellKeyLabel(key)}: no ${cell.clue.color} pearl continuation remains possible`, + cells: [key], } } } @@ -309,6 +315,7 @@ const detectLineLoopContradiction = ( let closedLoopCount = 0 let closedLoopLines = 0 + const contradictionLines: string[] = [] for (const component of getMasyuKnownLineComponents(puzzle)) { const { edgeCount, vertices } = component if (edgeCount !== vertices.size) { @@ -326,6 +333,17 @@ const detectLineLoopContradiction = ( } closedLoopCount += 1 closedLoopLines += edgeCount + for (const [lineKeyValue, line] of Object.entries(puzzle.lines)) { + if ((line?.mark ?? 'unknown') !== 'line') { + continue + } + const [left, right] = parseLineKey(lineKeyValue) + const leftIdx = toCellIndex(left[0], left[1]) + const rightIdx = toCellIndex(right[0], right[1]) + if (vertices.has(leftIdx) && vertices.has(rightIdx)) { + contradictionLines.push(lineKeyValue) + } + } } if ( @@ -338,6 +356,7 @@ const detectLineLoopContradiction = ( closedLoopCount > 1 ? `line-loop contradiction: ${closedLoopCount} separate closed Masyu loops are present` : `line-loop contradiction: a closed Masyu loop of ${closedLoopLines} segments exists while other line segments remain outside it`, + lines: contradictionLines, } } return null @@ -356,6 +375,9 @@ const detectTileColorContradiction = ( conflict.kind === 'relation' ? `tile-color contradiction at ${formatMasyuLineLabel(conflict.source)}: ${conflict.message}` : `tile-color contradiction at ${conflict.source}: ${conflict.message}`, + ...(conflict.kind === 'relation' + ? { lines: [conflict.source] } + : { tiles: [conflict.source] }), } } @@ -384,10 +406,12 @@ export const runMasyuTrialUntilFixpoint = ( stepsRun: 0, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason: initialContradictionReason, + traceSteps: [], } } let trial = puzzle + const traceSteps: TrialTraceStep[] = [] for (let stepNumber = 1; stepNumber <= maxTrialSteps; stepNumber += 1) { if (Date.now() > deadlineMs) { return { @@ -397,6 +421,7 @@ export const runMasyuTrialUntilFixpoint = ( puzzle: trial, stepsRun: stepNumber - 1, elapsedMs: Math.max(0, performance.now() - startedAt), + traceSteps, } } const { nextPuzzle, step } = runNextRule( @@ -414,9 +439,21 @@ export const runMasyuTrialUntilFixpoint = ( stepsRun: stepNumber - 1, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason: contradictionReason ?? undefined, + traceSteps, } } trial = nextPuzzle + traceSteps.push({ + ruleId: step.ruleId, + ruleName: step.ruleName, + message: step.message, + diffs: step.diffs, + affectedCells: step.affectedCells, + affectedEdges: step.affectedEdges, + affectedLines: step.affectedLines, + affectedTiles: step.affectedTiles, + affectedSectors: step.affectedSectors, + }) const contradictionReason = findMasyuHardContradictionReason(trial) if (contradictionReason) { return { @@ -427,6 +464,7 @@ export const runMasyuTrialUntilFixpoint = ( stepsRun: stepNumber, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason, + traceSteps, } } } @@ -438,5 +476,6 @@ export const runMasyuTrialUntilFixpoint = ( puzzle: trial, stepsRun: maxTrialSteps, elapsedMs: Math.max(0, performance.now() - startedAt), + traceSteps, } } diff --git a/src/domain/rules/masyu/rules/whitePearlStrongInference.ts b/src/domain/rules/masyu/rules/whitePearlStrongInference.ts index e581954..e6a0242 100644 --- a/src/domain/rules/masyu/rules/whitePearlStrongInference.ts +++ b/src/domain/rules/masyu/rules/whitePearlStrongInference.ts @@ -15,6 +15,7 @@ import { } from './shared' import { buildMasyuStrongBranch, + buildMasyuInferenceDetails, deriveMasyuStrongProbeBudgets, describeMasyuStrongTrialResult, immediateMasyuStrongContradictionResult, @@ -215,6 +216,13 @@ export const createWhitePearlStrongInferenceRule = ( diffs, affectedCells: [pearlCandidate.pearlKey], affectedLines: diffs.map((diff) => diff.lineKey), + inferenceDetails: buildMasyuInferenceDetails( + puzzle, + `Assume ${formatMasyuCellKeyLabel(pearlCandidate.pearlKey)} goes ${axisLabel(assumed.axis)}`, + branch, + result, + diffs, + ), } } } diff --git a/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts b/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts new file mode 100644 index 0000000..d8f478c --- /dev/null +++ b/src/domain/rules/masyu/tests/blackPearlStrongInference.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import type { Rule } from '../../types' +import { masyuPlugin } from '../../../plugins/masyuPlugin' +import { createBlackPearlStrongInferenceRule } from '../rules/blackPearlStrongInference' +import { deterministicMasyuRules } from '../rules' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu black pearl strong inference', () => { + it('crosses out a black pearl exit whose two-step assumption causes a degree contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + const north = lineKey([1, 2], [2, 2]) + + const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(2, 2)]) + expect(result?.affectedLines).toEqual([north]) + expect(result?.message).toContain('cell-degree contradiction') + expect(result?.inferenceDetails).toMatchObject({ + kind: 'masyu-strong', + defaultBranchId: 'assumption', + branches: [ + { + role: 'trial', + status: 'contradiction', + contradiction: { kind: 'cell-degree', cells: [cellKey(1, 2)] }, + }, + { + role: 'forced-conclusion', + status: 'forced', + initialDiffs: result?.diffs, + }, + ], + }) + }) + + it('uses deterministic downstream rules to find a contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + const north = lineKey([1, 2], [2, 2]) + const westOfNeighbor = lineKey([1, 1], [1, 2]) + const eastOfNeighbor = lineKey([1, 2], [1, 3]) + const downstreamRule: Rule = { + id: 'test-downstream-degree', + name: 'Test Downstream Degree', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[westOfNeighbor]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Force a downstream contradiction', + diffs: [ + { + kind: 'line', + lineKey: westOfNeighbor, + from: 'unknown', + to: 'line', + }, + { + kind: 'line', + lineKey: eastOfNeighbor, + from: 'unknown', + to: 'line', + }, + ], + affectedCells: [], + affectedLines: [westOfNeighbor, eastOfNeighbor], + } + }, + } + + const result = createBlackPearlStrongInferenceRule(() => [ + downstreamRule, + ]).apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.message).toContain('after 1 step') + expect(result?.inferenceDetails?.branches[0].traceSteps).toMatchObject([ + { + ruleId: 'test-downstream-degree', + affectedLines: [westOfNeighbor, eastOfNeighbor], + }, + ]) + }) + + it('does not copy a solved trial board back into the real puzzle', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + const north = lineKey([1, 2], [2, 2]) + const unrelated = lineKey([4, 3], [4, 4]) + const harmlessRule: Rule = { + id: 'test-harmless-trial-progress', + name: 'Test Harmless Trial Progress', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Harmless trial-only progress', + diffs: [ + { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, + ], + affectedCells: [], + affectedLines: [unrelated], + } + }, + } + + const result = createBlackPearlStrongInferenceRule(() => [harmlessRule], { + maxTrialSteps: 1, + }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[unrelated]?.mark).toBe('unknown') + }) + + it('returns null when the trial budget times out', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + + const result = createBlackPearlStrongInferenceRule(() => [], { + maxMs: -1, + }).apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not overwrite an already decided first exit segment', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + const north = lineKey([1, 2], [2, 2]) + markLine(puzzle, north, 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + + const result = createBlackPearlStrongInferenceRule(() => []).apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: north, + from: 'line', + to: 'blank', + }) + expect(puzzle.lines[north]?.mark).toBe('line') + }) + + it('registers strong inference after the deterministic Masyu rules', () => { + const rules = masyuPlugin.getRules() + + expect( + rules.slice(0, deterministicMasyuRules.length).map((rule) => rule.id), + ).toEqual(deterministicMasyuRules.map((rule) => rule.id)) + expect(rules.at(-3)?.id).toBe('masyu-black-pearl-strong-inference') + expect(rules.at(-2)?.id).toBe( + 'masyu-line-component-endpoint-strong-inference', + ) + expect(rules.at(-1)?.id).toBe('masyu-white-pearl-strong-inference') + }) +}) diff --git a/src/domain/rules/masyu/tests/bridges.test.ts b/src/domain/rules/masyu/tests/bridges.test.ts new file mode 100644 index 0000000..33ea3b2 --- /dev/null +++ b/src/domain/rules/masyu/tests/bridges.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { createMasyuCandidateBridgeLineRule } from '../rules/bridges' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu bridge rules', () => { + it('Masyu Candidate Bridge Line forces the only candidate bridge between two pearls', () => { + const puzzle = createMasyuPuzzle(1, 4) + addPearl(puzzle, 0, 0, 'white') + addPearl(puzzle, 0, 3, 'white') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + const bridge = lineKey([0, 1], [0, 2]) + + const result = createMasyuCandidateBridgeLineRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [bridge]: 'line' }) + expect(result?.affectedLines).toEqual([bridge]) + expect(result?.affectedCells).toEqual([cellKey(0, 1), cellKey(0, 2)]) + expect(result?.message).toContain('only remaining connection') + }) + + it('Masyu Candidate Bridge Line does not fire when two required groups have alternate routes', () => { + const puzzle = createMasyuPuzzle(2, 3) + addPearl(puzzle, 0, 0, 'white') + addPearl(puzzle, 0, 2, 'white') + + expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Candidate Bridge Line ignores a bridge into an ordinary dead-end region', () => { + const puzzle = createMasyuPuzzle(1, 3) + addPearl(puzzle, 0, 0, 'white') + addPearl(puzzle, 0, 1, 'white') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Candidate Bridge Line treats existing line endpoints as required sources', () => { + const puzzle = createMasyuPuzzle(1, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + addPearl(puzzle, 0, 3, 'black') + const bridge = lineKey([0, 1], [0, 2]) + + const result = createMasyuCandidateBridgeLineRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [bridge]: 'line' }) + }) + + it('Masyu Candidate Bridge Line skips a forced bridge that would overflow endpoint degree', () => { + const puzzle = createMasyuPuzzle(3, 4) + addPearl(puzzle, 1, 0, 'white') + addPearl(puzzle, 1, 3, 'white') + for (const key of Object.keys(puzzle.lines)) { + markLine(puzzle, key, 'blank') + } + markLine(puzzle, lineKey([1, 0], [1, 1]), 'unknown') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'unknown') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + + expect(createMasyuCandidateBridgeLineRule().apply(puzzle)).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/candidates.test.ts b/src/domain/rules/masyu/tests/candidates.test.ts new file mode 100644 index 0000000..40beea9 --- /dev/null +++ b/src/domain/rules/masyu/tests/candidates.test.ts @@ -0,0 +1,528 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import type { LineMark } from '../../../ir/types' +import { runNextRule } from '../../engine' +import { masyuPlugin } from '../../../plugins/masyuPlugin' +import { + createAdjacentWhitePearlsLookaheadRule, + createBlackPearlCandidatePruningRule, + createEmptyCellCandidatePruningRule, + createWhitePearlCandidatePruningRule, +} from '../rules/candidates' +import { createMasyuLookaheadContext } from '../rules/lookahead' +import { markLine, addPearl, getLineDegree, expectLineDiffs } from './testUtils' + +describe('Masyu black pearl candidate pruning', () => { + it('forces common exit and extension lines from the remaining black pearl candidates', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const northExtension = lineKey([0, 2], [1, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [northExtension]: 'line', + [south]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(2, 2)]) + }) + + it('removes a black pearl exit when every candidate using it leaves a nearby white pearl impossible', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 2, 4, 'white') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const west = lineKey([3, 2], [3, 3]) + const east = lineKey([3, 3], [3, 4]) + const northExtension = lineKey([1, 3], [2, 3]) + const westExtension = lineKey([3, 1], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'line', + [west]: 'line', + [northExtension]: 'line', + [westExtension]: 'line', + [east]: 'blank', + }) + }) + + it('removes an exit that would give an adjacent black pearl degree 3 through the extension line', () => { + const puzzle = createMasyuPuzzle(6, 7) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 3, 4, 'black') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'line') + markLine(puzzle, lineKey([2, 4], [3, 4]), 'line') + const east = lineKey([3, 3], [3, 4]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: lineKey([1, 3], [2, 3]), + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: east, + from: 'unknown', + to: 'blank', + }) + }) + + it('removes candidates whose required black pearl extension is already blank', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const southExtension = lineKey([3, 2], [4, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: south, + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: southExtension, + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).toContainEqual({ + kind: 'line', + lineKey: north, + from: 'unknown', + to: 'blank', + }) + }) + + it('removes a black pearl candidate that would close a smaller loop', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') + markLine(puzzle, lineKey([1, 3], [1, 4]), 'line') + markLine(puzzle, lineKey([1, 4], [2, 4]), 'line') + markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') + const northExtension = lineKey([0, 2], [1, 2]) + const east = lineKey([2, 2], [2, 3]) + const west = lineKey([2, 1], [2, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [northExtension]: 'line', + [west]: 'line', + [westExtension]: 'line', + [south]: 'blank', + [east]: 'blank', + }) + }) + + it('allows a black pearl candidate that closes the only confirmed loop component', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') + markLine(puzzle, lineKey([1, 3], [1, 4]), 'line') + markLine(puzzle, lineKey([1, 4], [2, 4]), 'line') + const northExtension = lineKey([0, 2], [1, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [northExtension]: 'line', + [south]: 'blank', + }) + }) + + it('checks affected white pearl dependencies outside the candidate touched cells', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 2, 4, 'white') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const west = lineKey([3, 2], [3, 3]) + const east = lineKey([3, 3], [3, 4]) + const northExtension = lineKey([1, 3], [2, 3]) + const westExtension = lineKey([3, 1], [3, 2]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'line', + [west]: 'line', + [northExtension]: 'line', + [westExtension]: 'line', + [east]: 'blank', + }) + }) + + it('ignores unrelated impossible pearls inside the old fixed 5x5 scan area', () => { + const puzzle = createMasyuPuzzle(7, 7) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 5, 1, 'white') + markLine(puzzle, lineKey([3, 2], [3, 3]), 'blank') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 1], [5, 1]), 'blank') + markLine(puzzle, lineKey([5, 0], [5, 1]), 'blank') + markLine(puzzle, lineKey([5, 1], [5, 2]), 'blank') + markLine(puzzle, lineKey([5, 1], [6, 1]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const northExtension = lineKey([1, 3], [2, 3]) + const east = lineKey([3, 3], [3, 4]) + const eastExtension = lineKey([3, 4], [3, 5]) + + const result = createBlackPearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'line', + [northExtension]: 'line', + [east]: 'line', + [eastExtension]: 'line', + }) + }) + + it('does not run on a fully determined black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('rejects a black pearl candidate that leaves an extension endpoint with no second exit', () => { + const puzzle = createMasyuPuzzle(4, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 3], [0, 4]), 'line') + markLine(puzzle, lineKey([0, 3], [1, 3]), 'line') + + expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('does nothing when all black pearl candidates remain symmetric', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + + expect(createBlackPearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('prunes only one black pearl per step on the reported 10x6 regression puzzle', () => { + let puzzle = masyuPlugin.parse( + 'https://puzz.link/p?mashu/10/6/0000b6103260i0902216', + ) + const rules = masyuPlugin + .getRules() + .filter((rule) => rule.id !== 'masyu-color-pearl-propagation') + let pruningStep: NonNullable< + ReturnType['step'] + > | null = null + + for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { + const result = runNextRule(puzzle, rules, stepNumber) + expect(result.step).not.toBeNull() + if (result.step?.ruleName === 'Black Pearl Candidate Pruning') { + pruningStep = result.step + break + } + puzzle = result.nextPuzzle + } + + expect(pruningStep?.affectedCells).toEqual([cellKey(1, 6)]) + expectLineDiffs(pruningStep?.diffs, { + [lineKey([1, 6], [1, 7])]: 'line', + [lineKey([1, 7], [1, 8])]: 'line', + [lineKey([1, 5], [1, 6])]: 'blank', + }) + }) + + it('does not let White Pearl Rule push the reported long puzzle into a degree-3 cell', () => { + const url = + 'https://puzz.link/p?mashu/49/39/0000000000i000000c63k0cj04962g6a430910i06390300109i20609090i30106000300400j00i100940iib01303c0646306110306j0010900f0306409064270i30112300030900000006a000390062216j09903i606230126c93a600000114000093009j63603004000040090099l0c919j00j41000l0343902030000k10963023990i0cia390399c02069200300930613i10013j0199ib0c00000460090a000i3j6iii013i0i1232090900c06960b00i323020000209j0909900996b690006463003k090396430000219900b02091610390021300l00c61a420b039i310201003030399010210i53026b690030a061132031003262120210a0ia30i30009190i3600601990300c00i30c31k0a203c019a0000090613ii00c26b0j206i0900130300093030023i09ic3b33b10i39310ia00030090060930000000130k090' + let puzzle = masyuPlugin.parse(url) + const unsafeLine = lineKey([2, 6], [2, 7]) + + for (let stepNumber = 1; stepNumber <= 35; stepNumber += 1) { + const result = runNextRule(puzzle, masyuPlugin.getRules(), stepNumber) + if (!result.step) { + break + } + expect(result.step.diffs).not.toContainEqual({ + kind: 'line', + lineKey: unsafeLine, + from: 'unknown', + to: 'line', + }) + puzzle = result.nextPuzzle + expect(getLineDegree(puzzle, 2, 6)).toBeLessThanOrEqual(2) + } + }) +}) + +describe('Masyu white pearl candidate pruning', () => { + it('forces the only feasible white pearl straight axis', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'white') + const east = lineKey([0, 1], [0, 2]) + const west = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 1], [1, 1]) + + const result = createWhitePearlCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [west]: 'line', + [south]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(0, 1)]) + expect(result?.message).toContain('White pearl') + }) + + it('does not force a white candidate line into a degree-2 neighbor', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + + expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('rejects a white pearl axis that leaves a neighboring empty cell with no second exit', () => { + const puzzle = createMasyuPuzzle(4, 5) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'line') + markLine(puzzle, lineKey([1, 0], [2, 0]), 'line') + markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + + expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('does nothing when both white straight-axis candidates remain symmetric', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + + expect(createWhitePearlCandidatePruningRule().apply(puzzle)).toBeNull() + }) +}) + +describe('Masyu adjacent white pearls lookahead', () => { + it('keeps parallel paths for horizontal white pearls when through is impossible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 3], [2, 3])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + [lineKey([2, 2], [2, 3])]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(2, 2), cellKey(2, 3)]) + expect(result?.message).toContain('parallel') + }) + + it('keeps one horizontal through-line when parallel is impossible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 2], [2, 3])]: 'line', + [lineKey([2, 1], [2, 2])]: 'line', + [lineKey([2, 3], [2, 4])]: 'line', + [lineKey([2, 2], [3, 2])]: 'blank', + [lineKey([2, 3], [3, 3])]: 'blank', + }) + expect(result?.message).toContain('one straight line') + }) + + it('keeps parallel paths for vertical white pearls when through is impossible', () => { + const puzzle = createMasyuPuzzle(6, 5) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 3, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 1], [2, 2])]: 'line', + [lineKey([2, 2], [2, 3])]: 'line', + [lineKey([3, 1], [3, 2])]: 'line', + [lineKey([3, 2], [3, 3])]: 'line', + [lineKey([2, 2], [3, 2])]: 'blank', + }) + }) + + it('keeps one vertical through-line when parallel is impossible', () => { + const puzzle = createMasyuPuzzle(6, 5) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 3, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([3, 1], [3, 2]), 'blank') + + const result = createAdjacentWhitePearlsLookaheadRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([3, 2], [4, 2])]: 'line', + [lineKey([2, 2], [2, 3])]: 'blank', + [lineKey([3, 2], [3, 3])]: 'blank', + }) + }) + + it('does nothing when both adjacent-white patterns remain feasible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + + expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() + }) + + it('does nothing when both adjacent-white patterns are impossible', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + + expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() + }) + + it('does not force the remaining adjacent-white pattern through a degree-2 cell', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 1], [3, 1]), 'line') + + expect(createAdjacentWhitePearlsLookaheadRule().apply(puzzle)).toBeNull() + }) + + it('rejects the reported adjacent-white horizontal assumption when it creates a single-exit empty cell', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 6, 6, 'black') + addPearl(puzzle, 4, 8, 'white') + addPearl(puzzle, 5, 8, 'white') + markLine(puzzle, lineKey([4, 6], [5, 6]), 'line') + markLine(puzzle, lineKey([5, 6], [6, 6]), 'line') + markLine(puzzle, lineKey([4, 6], [4, 7]), 'line') + markLine(puzzle, lineKey([6, 6], [6, 7]), 'line') + markLine(puzzle, lineKey([6, 7], [6, 8]), 'line') + + const horizontalThroughOverlay = new Map([ + [lineKey([5, 7], [5, 8]), 'line'], + [lineKey([5, 8], [5, 9]), 'line'], + [lineKey([4, 7], [4, 8]), 'line'], + [lineKey([4, 8], [4, 9]), 'line'], + ]) + + expect( + createMasyuLookaheadContext(puzzle).isOverlayLocallyFeasible( + [cellKey(4, 8), cellKey(5, 8)], + horizontalThroughOverlay, + ), + ).toBe(false) + }) +}) + +describe('Masyu empty cell candidate pruning', () => { + it('skips an unconstrained center empty cell with four unknown exits', () => { + const puzzle = createMasyuPuzzle(3, 3) + + expect(createEmptyCellCandidatePruningRule().apply(puzzle)).toBeNull() + }) + + it('forces the only corner continuation from a degree-1 empty cell', () => { + const puzzle = createMasyuPuzzle(2, 2) + const south = lineKey([0, 0], [1, 0]) + const east = lineKey([0, 0], [0, 1]) + markLine(puzzle, south, 'line') + + const result = createEmptyCellCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(0, 0)]) + expect(result?.message).toContain('Empty cell') + }) + + it('rejects candidates that would overflow a touched cell degree', () => { + const puzzle = createMasyuPuzzle(2, 4) + const target = cellKey(0, 1) + const east = lineKey([0, 1], [0, 2]) + const west = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 1], [1, 1]) + markLine(puzzle, west, 'blank') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + + const result = createEmptyCellCandidatePruningRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank' }) + }) + + it('rejects candidates that make a nearby white pearl locally impossible', () => { + const puzzle = createMasyuPuzzle(2, 4) + addPearl(puzzle, 0, 2, 'white') + const westOfEmpty = lineKey([0, 0], [0, 1]) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + + const result = createEmptyCellCandidatePruningRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [westOfEmpty]: 'line', + [lineKey([0, 0], [1, 0])]: 'line', + }) + }) + + it('does not rely on downstream deterministic propagation inside candidates', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'unknown') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'unknown') + + expect(createEmptyCellCandidatePruningRule().apply(puzzle)).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/cellExitCompletion.test.ts b/src/domain/rules/masyu/tests/cellExitCompletion.test.ts new file mode 100644 index 0000000..7db509e --- /dev/null +++ b/src/domain/rules/masyu/tests/cellExitCompletion.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { createCellExitCompletionRule } from '../rules/completion' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu pearl cell exit completion', () => { + it('forces the opposite white pearl exit when only the straight continuation remains', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('forces the only available straight pair on a white pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'line', [south]: 'line' }) + }) + + it('blanks remaining white pearl exits after a straight pair is complete', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + const west = lineKey([2, 1], [2, 2]) + const east = lineKey([2, 2], [2, 3]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'blank', [east]: 'blank' }) + }) + + it('does not force a white pearl when only a turning pair remains', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + + expect(createCellExitCompletionRule().apply(puzzle)).toBeNull() + }) + + it('forces a black pearl turn continuation without extending exits', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('forces the only available turning pair on a black pearl without extending it', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const west = lineKey([2, 1], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [west]: 'line', + [south]: 'line', + }) + }) + + it('blanks remaining exits after a completed black pearl turn', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [south]: 'blank', + }) + }) + + it('does not force a black pearl when only a straight pair remains', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + + expect(createCellExitCompletionRule().apply(puzzle)).toBeNull() + }) + + it('does not inspect black pearl extensions', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) +}) + +describe('Masyu regular cell exit completion', () => { + it('connects the only remaining candidate when a regular cell has one line', () => { + const puzzle = createMasyuPuzzle(3, 3) + const west = lineKey([1, 1], [1, 0]) + const east = lineKey([1, 1], [1, 2]) + markLine(puzzle, west, 'line') + markLine(puzzle, lineKey([1, 1], [0, 1]), 'blank') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line' }) + }) + + it('blanks every remaining candidate when a cell already has degree 2', () => { + const puzzle = createMasyuPuzzle(3, 3) + const north = lineKey([1, 1], [0, 1]) + const south = lineKey([1, 1], [2, 1]) + markLine(puzzle, lineKey([1, 1], [1, 0]), 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) + }) + + it('blanks a single remaining candidate on a regular dead-end cell', () => { + const puzzle = createMasyuPuzzle(1, 2) + const onlyLine = lineKey([0, 0], [0, 1]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [onlyLine]: 'blank' }) + }) + + it('uses white pearl completion when traversing all cells', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const north = lineKey([1, 2], [2, 2]) + const east = lineKey([2, 2], [2, 3]) + const south = lineKey([2, 2], [3, 2]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'blank', + [east]: 'line', + [south]: 'blank', + }) + }) + + it('uses black pearl completion when traversing all cells', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + + const result = createCellExitCompletionRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank' }) + }) +}) diff --git a/src/domain/rules/masyu/tests/color.test.ts b/src/domain/rules/masyu/tests/color.test.ts new file mode 100644 index 0000000..40c9766 --- /dev/null +++ b/src/domain/rules/masyu/tests/color.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey, tileKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { + createMasyuColorLinePropagationRule, + createMasyuColorPearlPropagationRule, + createMasyuTileColorPropagationRule, +} from '../rules/color' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu color rules', () => { + it('Masyu Tile Color Propagation seeds boundary tiles yellow', () => { + const puzzle = createMasyuPuzzle(2, 2) + const result = createMasyuTileColorPropagationRule().apply(puzzle) + const fills = new Map( + (result?.diffs ?? []).flatMap((diff) => + diff.kind === 'tile' ? [[diff.tileKey, diff.toFill] as const] : [], + ), + ) + + expect(fills.size).toBe(8) + for (let row = 0; row <= 2; row += 1) { + for (let col = 0; col <= 2; col += 1) { + const key = tileKey(row, col) + if (row === 1 && col === 1) { + expect(fills.has(key)).toBe(false) + } else { + expect(fills.get(key)).toBe('yellow') + } + } + } + }) + + it('Masyu Tile Color Propagation carries color through blank lines', () => { + const puzzle = createMasyuPuzzle(2, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'blank') + + const result = createMasyuTileColorPropagationRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'tile', + tileKey: tileKey(1, 1), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('Masyu Tile Color Propagation flips color across line segments', () => { + const puzzle = createMasyuPuzzle(2, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const result = createMasyuTileColorPropagationRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'tile', + tileKey: tileKey(1, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('Masyu Tile Color Propagation uses existing tile color anchors', () => { + const puzzle = createMasyuPuzzle(3, 3) + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + markLine(puzzle, lineKey([0, 1], [1, 1]), 'blank') + + const result = createMasyuTileColorPropagationRule().apply(puzzle) + + expect(result?.diffs).toContainEqual({ + kind: 'tile', + tileKey: tileKey(1, 2), + fromFill: null, + toFill: 'green', + }) + }) + + it('Masyu Color-Pearl Propagation colors the opposite diagonal from a white pearl NW tile', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + const result = createMasyuColorPearlPropagationRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { + kind: 'tile', + tileKey: tileKey(2, 2), + fromFill: null, + toFill: 'yellow', + }, + ]) + expect(result?.affectedCells).toEqual([cellKey(1, 1)]) + expect(result?.affectedTiles).toEqual([tileKey(1, 1), tileKey(2, 2)]) + expect(result?.message).toContain('White pearl') + }) + + it('Masyu Color-Pearl Propagation colors the opposite diagonal from a white pearl NE tile', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 2)] = { fill: 'yellow' } + + const result = createMasyuColorPearlPropagationRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 1), fromFill: null, toFill: 'green' }, + ]) + }) + + it('Masyu Color-Pearl Propagation ignores black pearls', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'black') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + expect(createMasyuColorPearlPropagationRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Color-Pearl Propagation does not overwrite an already colored opposite diagonal', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'green' } + + expect(createMasyuColorPearlPropagationRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Color-Line Propagation forces a line between different adjacent tile colors', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [1, 2]) + puzzle.tiles[tileKey(1, 2)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + + const result = createMasyuColorLinePropagationRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [targetLine]: 'line' }) + expect(result?.affectedLines).toEqual([targetLine]) + expect(result?.affectedTiles).toEqual([tileKey(1, 2), tileKey(2, 2)]) + expect(result?.message).toContain('different colors') + }) + + it('Masyu Color-Line Propagation crosses a line between same adjacent tile colors', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [2, 1]) + puzzle.tiles[tileKey(2, 1)] = { fill: 'yellow' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + + const result = createMasyuColorLinePropagationRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [targetLine]: 'blank' }) + expect(result?.message).toContain('same color') + }) + + it('Masyu Color-Line Propagation uses boundary tile colors beside edge-adjacent lines', () => { + const puzzle = createMasyuPuzzle(2, 2) + const targetLine = lineKey([0, 0], [0, 1]) + puzzle.tiles[tileKey(0, 1)] = { fill: 'yellow' } + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + const result = createMasyuColorLinePropagationRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [targetLine]: 'line' }) + }) + + it('Masyu Color-Line Propagation does not overwrite an already decided line', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [1, 2]) + puzzle.tiles[tileKey(1, 2)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + markLine(puzzle, targetLine, 'blank') + + expect(createMasyuColorLinePropagationRule().apply(puzzle)).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/connectivity.test.ts b/src/domain/rules/masyu/tests/connectivity.test.ts new file mode 100644 index 0000000..e015a7e --- /dev/null +++ b/src/domain/rules/masyu/tests/connectivity.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { lineKey, tileKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { createMasyuTileConnectivityCutColoringRule } from '../rules/connectivity' +import { markLine, fillAllTiles } from './testUtils' + +describe('Masyu connectivity rules', () => { + it('Masyu Tile Connectivity Cut Coloring colors an articulation tile green between green sources', () => { + const puzzle = createMasyuPuzzle(4, 4) + fillAllTiles(puzzle, 'yellow') + puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = {} + puzzle.tiles[tileKey(2, 3)] = { fill: 'green' } + + const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'green' }, + ]) + expect(result?.affectedTiles).toEqual([tileKey(2, 2)]) + expect(result?.message).toContain('inside cuts 1') + }) + + it('Masyu Tile Connectivity Cut Coloring colors every unknown tile in a blank-compressed bottleneck', () => { + const puzzle = createMasyuPuzzle(4, 5) + fillAllTiles(puzzle, 'yellow') + puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = {} + puzzle.tiles[tileKey(2, 3)] = {} + puzzle.tiles[tileKey(2, 4)] = { fill: 'green' } + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + + const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(2, 2), fromFill: null, toFill: 'green' }, + { kind: 'tile', tileKey: tileKey(2, 3), fromFill: null, toFill: 'green' }, + ]) + }) + + it('Masyu Tile Connectivity Cut Coloring colors a line-enclosed tile green', () => { + const puzzle = createMasyuPuzzle(2, 2) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + + const result = createMasyuTileConnectivityCutColoringRule().apply(puzzle) + + expect(result?.diffs).toEqual([ + { kind: 'tile', tileKey: tileKey(1, 1), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('unreachable-from-outside 1') + }) + + it('Masyu Tile Connectivity Cut Coloring keeps unknown line separators passable', () => { + const puzzle = createMasyuPuzzle(2, 2) + + expect( + createMasyuTileConnectivityCutColoringRule().apply(puzzle), + ).toBeNull() + }) + + it('Masyu Tile Connectivity Cut Coloring does not fire with only one green source component', () => { + const puzzle = createMasyuPuzzle(4, 4) + fillAllTiles(puzzle, 'yellow') + puzzle.tiles[tileKey(2, 1)] = { fill: 'green' } + puzzle.tiles[tileKey(2, 2)] = {} + + expect( + createMasyuTileConnectivityCutColoringRule().apply(puzzle), + ).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts b/src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts new file mode 100644 index 0000000..b7e9b54 --- /dev/null +++ b/src/domain/rules/masyu/tests/lineComponentEndpointStrongInference.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import type { Rule } from '../../types' +import { createLineComponentEndpointStrongInferenceRule } from '../rules/lineComponentEndpointStrongInference' +import { addPearl, expectLineDiffs, markLine } from './testUtils' + +describe('Masyu line component endpoint strong inference', () => { + it('crosses out a two-direction endpoint continuation that causes a contradiction', () => { + const puzzle = createMasyuPuzzle(3, 4) + const endpoint = cellKey(0, 1) + const west = lineKey([0, 0], [0, 1]) + const east = lineKey([0, 1], [0, 2]) + markLine(puzzle, west, 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + + const result = createLineComponentEndpointStrongInferenceRule(() => []).apply( + puzzle, + ) + + expect(result?.affectedCells).toEqual([endpoint]) + expectLineDiffs(result?.diffs, { [east]: 'blank' }) + expect(result?.message).toContain('1-segment component') + expect(result?.message).toContain('among 2 candidate directions') + expect(result?.inferenceDetails?.branches[0]).toMatchObject({ + status: 'contradiction', + contradiction: { kind: 'cell-degree', cells: [cellKey(0, 2)] }, + }) + expect(result?.inferenceDetails?.branches[1]).toMatchObject({ + status: 'forced', + initialDiffs: result?.diffs, + }) + }) + + it('tests a three-direction endpoint and only crosses out the failing direction', () => { + const puzzle = createMasyuPuzzle(4, 4) + const west = lineKey([1, 0], [1, 1]) + const north = lineKey([0, 1], [1, 1]) + const east = lineKey([1, 1], [1, 2]) + const south = lineKey([1, 1], [2, 1]) + markLine(puzzle, west, 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank') + markLine(puzzle, lineKey([1, 0], [2, 0]), 'blank') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [0, 2]), 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'blank') + + const result = createLineComponentEndpointStrongInferenceRule(() => []).apply( + puzzle, + ) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.message).toContain('among 3 candidate directions') + expect(puzzle.lines[east]?.mark).toBe('unknown') + expect(puzzle.lines[south]?.mark).toBe('unknown') + }) + + it('prioritizes endpoints with fewer candidate directions', () => { + const puzzle = createMasyuPuzzle(5, 5) + const constrainedEndpoint = cellKey(0, 1) + const constrainedEast = lineKey([0, 1], [0, 2]) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + + markLine(puzzle, lineKey([3, 2], [3, 3]), 'line') + markLine(puzzle, lineKey([3, 1], [3, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'blank') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'blank') + + const result = createLineComponentEndpointStrongInferenceRule(() => []).apply( + puzzle, + ) + + expect(result?.affectedCells).toEqual([constrainedEndpoint]) + expectLineDiffs(result?.diffs, { [constrainedEast]: 'blank' }) + }) + + it('prioritizes longer components when endpoint candidate counts match', () => { + const puzzle = createMasyuPuzzle(6, 6) + const shortEast = lineKey([0, 1], [0, 2]) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 0], [1, 0]), 'blank') + + const longNorth = lineKey([3, 3], [4, 3]) + markLine(puzzle, lineKey([4, 0], [4, 1]), 'line') + markLine(puzzle, lineKey([4, 1], [4, 2]), 'line') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'line') + markLine(puzzle, lineKey([3, 0], [4, 0]), 'blank') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'blank') + + const centerLines = [ + lineKey([2, 1], [2, 2]), + lineKey([2, 2], [2, 3]), + lineKey([1, 2], [2, 2]), + ] + const contradictionRule: Rule = { + id: 'test-long-component-priority', + name: 'Test Long Component Priority', + apply: (trial) => + trial.lines[longNorth]?.mark === 'line' || + trial.lines[shortEast]?.mark === 'line' + ? { + message: 'Force a priority-test contradiction', + diffs: centerLines.map((lineKeyValue) => ({ + kind: 'line' as const, + lineKey: lineKeyValue, + from: 'unknown' as const, + to: 'line' as const, + })), + affectedCells: [], + affectedLines: centerLines, + } + : null, + } + + const result = createLineComponentEndpointStrongInferenceRule(() => [ + contradictionRule, + ]).apply(puzzle) + + expectLineDiffs(result?.diffs, { [longNorth]: 'blank' }) + expect(result?.message).toContain('3-segment component') + }) + + it('can inspect a pearl that is also a component endpoint', () => { + const puzzle = createMasyuPuzzle(5, 5) + const endpoint = cellKey(2, 2) + const north = lineKey([1, 2], [2, 2]) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 0], [2, 0]), 'blank') + markLine(puzzle, lineKey([2, 0], [3, 0]), 'blank') + + const result = createLineComponentEndpointStrongInferenceRule(() => []).apply( + puzzle, + ) + + expect(result?.affectedCells).toEqual([endpoint]) + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.message).toContain('component at (R3, C3)') + }) + + it('ignores branched line components', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'line') + + const result = createLineComponentEndpointStrongInferenceRule(() => []).apply( + puzzle, + ) + + expect(result).toBeNull() + }) + + it('ignores closed components and endpoints with only one unknown exit', () => { + const closed = createMasyuPuzzle(2, 2) + markLine(closed, lineKey([0, 0], [0, 1]), 'line') + markLine(closed, lineKey([0, 1], [1, 1]), 'line') + markLine(closed, lineKey([1, 0], [1, 1]), 'line') + markLine(closed, lineKey([0, 0], [1, 0]), 'line') + + const singleExit = createMasyuPuzzle(1, 3) + markLine(singleExit, lineKey([0, 0], [0, 1]), 'line') + + const rule = createLineComponentEndpointStrongInferenceRule(() => []) + expect(rule.apply(closed)).toBeNull() + expect(rule.apply(singleExit)).toBeNull() + }) + + it('does not copy harmless trial progress back into the real puzzle', () => { + const puzzle = createMasyuPuzzle(3, 4) + const east = lineKey([0, 1], [0, 2]) + const unrelated = lineKey([2, 2], [2, 3]) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + const harmlessRule: Rule = { + id: 'test-endpoint-harmless-progress', + name: 'Test Endpoint Harmless Progress', + apply: (trial) => + trial.lines[east]?.mark === 'line' && + trial.lines[unrelated]?.mark === 'unknown' + ? { + message: 'Harmless endpoint trial progress', + diffs: [ + { + kind: 'line', + lineKey: unrelated, + from: 'unknown', + to: 'line', + }, + ], + affectedCells: [], + affectedLines: [unrelated], + } + : null, + } + + const result = createLineComponentEndpointStrongInferenceRule(() => [ + harmlessRule, + ], { maxTrialSteps: 1 }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[unrelated]?.mark).toBe('unknown') + }) + + it('honors timeout and candidate limits', () => { + const puzzle = createMasyuPuzzle(3, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + + expect( + createLineComponentEndpointStrongInferenceRule(() => [], { + maxMs: -1, + }).apply(puzzle), + ).toBeNull() + expect( + createLineComponentEndpointStrongInferenceRule(() => [], { + maxCandidates: 1, + }).apply(puzzle), + ).not.toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/loop.test.ts b/src/domain/rules/masyu/tests/loop.test.ts new file mode 100644 index 0000000..7c751d3 --- /dev/null +++ b/src/domain/rules/masyu/tests/loop.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { + createMasyuEmptyCellPrematureLoopRule, + createPreventPrematureLoopRule, +} from '../rules/loop' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu loop rules', () => { + it('Prevent Premature Loop blanks a line that would close a smaller loop while other lines remain outside', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + const closingLine = lineKey([0, 0], [1, 0]) + + const result = createPreventPrematureLoopRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [closingLine]: 'blank' }) + expect(result?.affectedLines).toEqual([closingLine]) + expect(result?.message).toContain('smaller loop') + }) + + it('Prevent Premature Loop does not blank a candidate whose endpoints are in different components', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() + }) + + it('Prevent Premature Loop allows a closing line when no other confirmed lines remain outside', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + + expect(createPreventPrematureLoopRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Empty Cell Premature Loop blanks both exits when using both would close a smaller loop', () => { + const puzzle = createMasyuPuzzle(4, 4) + const target = cellKey(0, 0) + const east = lineKey([0, 0], [0, 1]) + const south = lineKey([0, 0], [1, 0]) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + const result = createMasyuEmptyCellPrematureLoopRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([target]) + expectLineDiffs(result?.diffs, { [east]: 'blank', [south]: 'blank' }) + expect(result?.affectedLines).toEqual([east, south]) + expect(result?.message).toContain('both remaining exits') + expect(result?.message).toContain('smaller loop') + }) + + it('Masyu Empty Cell Premature Loop skips cells that already have a line', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() + }) + + it('Masyu Empty Cell Premature Loop skips pearl cells', () => { + for (const color of ['black', 'white'] as const) { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 0, 0, color) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + + expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() + } + }) + + it('Masyu Empty Cell Premature Loop allows both exits when no other confirmed lines remain outside', () => { + const puzzle = createMasyuPuzzle(4, 4) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + + expect(createMasyuEmptyCellPrematureLoopRule().apply(puzzle)).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/patterns.test.ts b/src/domain/rules/masyu/tests/patterns.test.ts new file mode 100644 index 0000000..57f4fde --- /dev/null +++ b/src/domain/rules/masyu/tests/patterns.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { runNextRule } from '../../engine' +import { masyuPlugin } from '../../../plugins/masyuPlugin' +import { + createBlackDiagonalWhitePinchRule, + createBlackFacingConsecutiveWhitesRule, + createConsecutiveWhitePearlsStraightRule, + createDoubleBlackSqueezeRule, + createWhiteCorridorRule, +} from '../rules/patterns' +import { deterministicMasyuRules } from '../rules' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu pattern rules', () => { + it('Black Facing Consecutive Whites forces away from horizontal consecutive white pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 1, 'black') + addPearl(puzzle, 2, 3, 'white') + addPearl(puzzle, 2, 4, 'white') + const west = lineKey([2, 0], [2, 1]) + + const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(2, 1)]) + }) + + it('Black Facing Consecutive Whites forces away from vertical consecutive white pearls', () => { + const puzzle = createMasyuPuzzle(6, 5) + addPearl(puzzle, 3, 2, 'black') + addPearl(puzzle, 1, 2, 'white') + addPearl(puzzle, 0, 2, 'white') + const south = lineKey([3, 2], [4, 2]) + + const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('Black Facing Consecutive Whites does not fire with only one distant white pearl', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 1, 'black') + addPearl(puzzle, 2, 3, 'white') + + expect(createBlackFacingConsecutiveWhitesRule().apply(puzzle)).toBeNull() + }) + + it('Black Facing Consecutive Whites allows the gap cell to contain a pearl', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 1, 'black') + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 3, 'white') + addPearl(puzzle, 2, 4, 'white') + const west = lineKey([2, 0], [2, 1]) + + const result = createBlackFacingConsecutiveWhitesRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls north of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 1, 3, 'white') + const south = lineKey([2, 2], [3, 2]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls east of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 3, 'white') + addPearl(puzzle, 3, 3, 'white') + const west = lineKey([2, 1], [2, 2]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [west]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls south of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 3, 1, 'white') + addPearl(puzzle, 3, 3, 'white') + const north = lineKey([1, 2], [2, 2]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'line' }) + }) + + it('Black Diagonal White Pinch forces away from white pearls west of a black pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 3, 1, 'white') + const east = lineKey([2, 2], [2, 3]) + + const result = createBlackDiagonalWhitePinchRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line' }) + }) + + it('Black Diagonal White Pinch does not fire when one diagonal white pearl is missing', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 3, 'white') + + expect(createBlackDiagonalWhitePinchRule().apply(puzzle)).toBeNull() + }) + + it('Black Diagonal White Pinch does not overwrite a blank forced line', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 1, 3, 'white') + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + + expect(createBlackDiagonalWhitePinchRule().apply(puzzle)).toBeNull() + }) + + it('Consecutive White Pearls Straight forces vertical pass-through for three horizontal white pearls', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 1, 'white') + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([1, 1], [2, 1])]: 'line', + [lineKey([2, 1], [3, 1])]: 'line', + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 3], [2, 3])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + }) + }) + + it('Consecutive White Pearls Straight forces horizontal pass-through for three vertical white pearls', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 1, 2, 'white') + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 3, 2, 'white') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([1, 1], [1, 2])]: 'line', + [lineKey([1, 2], [1, 3])]: 'line', + [lineKey([2, 1], [2, 2])]: 'line', + [lineKey([2, 2], [2, 3])]: 'line', + [lineKey([3, 1], [3, 2])]: 'line', + [lineKey([3, 2], [3, 3])]: 'line', + }) + }) + + it('Consecutive White Pearls Straight covers every pearl in a longer run', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 1, 'white') + addPearl(puzzle, 3, 2, 'white') + addPearl(puzzle, 3, 3, 'white') + addPearl(puzzle, 3, 4, 'white') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 1], [3, 1])]: 'line', + [lineKey([3, 1], [4, 1])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([3, 2], [4, 2])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + [lineKey([3, 3], [4, 3])]: 'line', + [lineKey([2, 4], [3, 4])]: 'line', + [lineKey([3, 4], [4, 4])]: 'line', + }) + }) + + it('Consecutive White Pearls Straight does not fire for only two consecutive white pearls', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 1, 'white') + addPearl(puzzle, 2, 2, 'white') + + expect(createConsecutiveWhitePearlsStraightRule().apply(puzzle)).toBeNull() + }) + + it('Consecutive White Pearls Straight does not overwrite a blank target line', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 1, 'white') + addPearl(puzzle, 2, 2, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'blank') + + const result = createConsecutiveWhitePearlsStraightRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 1], [3, 1])]: 'line', + [lineKey([1, 2], [2, 2])]: 'line', + [lineKey([2, 2], [3, 2])]: 'line', + [lineKey([1, 3], [2, 3])]: 'line', + [lineKey([2, 3], [3, 3])]: 'line', + }) + }) + + it('White Corridor forces the two inner white pearls through the L-shaped corridor', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 5, 4, 'white') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') + markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const result = createWhiteCorridorRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([4, 4], [4, 5])]: 'line', + [lineKey([4, 5], [4, 6])]: 'line', + [lineKey([4, 4], [5, 4])]: 'line', + [lineKey([5, 4], [6, 4])]: 'line', + }) + expect(result?.affectedCells).toEqual([ + cellKey(3, 3), + cellKey(4, 5), + cellKey(5, 4), + ]) + }) + + it('White Corridor works after rotation', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 5, 2, 'white') + addPearl(puzzle, 4, 1, 'white') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + markLine(puzzle, lineKey([3, 3], [3, 2]), 'line') + markLine(puzzle, lineKey([3, 2], [3, 1]), 'line') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + + const result = createWhiteCorridorRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([4, 2], [5, 2])]: 'line', + [lineKey([5, 2], [6, 2])]: 'line', + [lineKey([4, 1], [4, 2])]: 'line', + [lineKey([4, 0], [4, 1])]: 'line', + }) + }) + + it('White Corridor does not fire before there are multiple known line components', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 5, 4, 'white') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') + markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + + expect(createWhiteCorridorRule().apply(puzzle)).toBeNull() + }) + + it('White Corridor does not overwrite a blank forced line', () => { + const puzzle = createMasyuPuzzle(8, 8) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 5, 4, 'white') + markLine(puzzle, lineKey([3, 3], [3, 4]), 'line') + markLine(puzzle, lineKey([3, 4], [3, 5]), 'line') + markLine(puzzle, lineKey([3, 3], [4, 3]), 'line') + markLine(puzzle, lineKey([4, 3], [5, 3]), 'line') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([4, 4], [4, 5]), 'blank') + + expect(createWhiteCorridorRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze blanks the opposite vertical exit between horizontal black pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'black') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + const south = lineKey([2, 3], [3, 3]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(2, 3)]) + }) + + it('Double Black Squeeze works in the reverse vertical direction between horizontal black pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'black') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'blank') + const north = lineKey([1, 3], [2, 3]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + }) + + it('Double Black Squeeze blanks the opposite horizontal exit between vertical black pearls', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 2, 3, 'black') + addPearl(puzzle, 4, 3, 'black') + markLine(puzzle, lineKey([3, 2], [3, 3]), 'blank') + const east = lineKey([3, 3], [3, 4]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank' }) + }) + + it('Double Black Squeeze treats the bottom edge as a crossed-out perpendicular exit', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 9, 5, 'black') + addPearl(puzzle, 9, 7, 'black') + const north = lineKey([8, 6], [9, 6]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(9, 6)]) + }) + + it('Double Black Squeeze treats the top edge as a crossed-out perpendicular exit', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 0, 2, 'black') + addPearl(puzzle, 0, 4, 'black') + const south = lineKey([0, 3], [1, 3]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'blank' }) + expect(result?.affectedCells).toEqual([cellKey(0, 3)]) + }) + + it('Double Black Squeeze treats side edges as crossed-out perpendicular exits', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 2, 0, 'black') + addPearl(puzzle, 4, 0, 'black') + addPearl(puzzle, 5, 9, 'black') + addPearl(puzzle, 7, 9, 'black') + const rightFromLeftEdge = lineKey([3, 0], [3, 1]) + const leftFromRightEdge = lineKey([6, 8], [6, 9]) + + const result = createDoubleBlackSqueezeRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [rightFromLeftEdge]: 'blank', + [leftFromRightEdge]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(3, 0), cellKey(6, 9)]) + }) + + it('Double Black Squeeze does not overwrite a border perpendicular exit that is already a line', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 9, 5, 'black') + addPearl(puzzle, 9, 7, 'black') + markLine(puzzle, lineKey([8, 6], [9, 6]), 'line') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze handles the supplied bottom-edge puzzle without strong inference', () => { + let puzzle = masyuPlugin.parse( + 'https://puzz.link/p?mashu/10/10/0000000000000000000000000300000260', + ) + const rules = deterministicMasyuRules + const expectedLines = [ + lineKey([7, 5], [8, 5]), + lineKey([8, 5], [9, 5]), + lineKey([7, 7], [8, 7]), + lineKey([8, 7], [9, 7]), + ] + const squeezed = lineKey([8, 6], [9, 6]) + let sawDoubleBlackSqueeze = false + + for (let stepNumber = 1; stepNumber <= 8; stepNumber += 1) { + const result = runNextRule(puzzle, rules, stepNumber) + if (!result.step) { + break + } + sawDoubleBlackSqueeze ||= result.step.ruleName === 'Double Black Squeeze' + puzzle = result.nextPuzzle + } + + expect(sawDoubleBlackSqueeze).toBe(true) + expect(puzzle.lines[squeezed]?.mark).toBe('blank') + for (const key of expectedLines) { + expect(puzzle.lines[key]?.mark).toBe('line') + } + }) + + it('Double Black Squeeze does not fire unless both opposite cells are black pearls', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'white') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze ignores adjacent black pearls that do not enclose a middle cell', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 3, 'black') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) + + it('Double Black Squeeze does not overwrite an opposite exit that is already a line', () => { + const puzzle = createMasyuPuzzle(5, 6) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 2, 4, 'black') + markLine(puzzle, lineKey([1, 3], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 3], [3, 3]), 'line') + + expect(createDoubleBlackSqueezeRule().apply(puzzle)).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/pearls.test.ts b/src/domain/rules/masyu/tests/pearls.test.ts new file mode 100644 index 0000000..437d876 --- /dev/null +++ b/src/domain/rules/masyu/tests/pearls.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { createBlackPearlRule, createWhitePearlRule } from '../rules/pearls' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu pearl rules', () => { + it('White Pearl Rule blanks a blocked axis and forces the other straight axis', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'white') + const south = lineKey([0, 1], [1, 1]) + const east = lineKey([0, 1], [0, 2]) + const west = lineKey([0, 1], [0, 0]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [south]: 'blank', + [east]: 'line', + [west]: 'line', + }) + expect(result?.affectedCells).toEqual([cellKey(0, 1)]) + }) + + it('White Pearl Rule uses an existing blank to force the crossing axis', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + const east = lineKey([1, 1], [1, 2]) + const west = lineKey([1, 1], [1, 0]) + const north = lineKey([1, 1], [0, 1]) + const south = lineKey([1, 1], [2, 1]) + markLine(puzzle, east, 'blank') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [west]: 'blank', + [north]: 'line', + [south]: 'line', + }) + }) + + it('White Pearl Rule does nothing when both straight axes are still available', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 1, 1, 'white') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + + it('White Pearl Rule rejects a vertical pass-through when both immediate turn cells are blocked', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + const north = lineKey([2, 3], [3, 3]) + const south = lineKey([3, 3], [4, 3]) + const east = lineKey([3, 3], [3, 4]) + const west = lineKey([3, 2], [3, 3]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [north]: 'blank', + [south]: 'blank', + [east]: 'line', + [west]: 'line', + }) + }) + + it('White Pearl Rule rejects a horizontal pass-through when both immediate turn cells are blocked', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + markLine(puzzle, lineKey([2, 4], [3, 4]), 'blank') + markLine(puzzle, lineKey([3, 4], [4, 4]), 'blank') + const east = lineKey([3, 3], [3, 4]) + const west = lineKey([3, 2], [3, 3]) + const north = lineKey([2, 3], [3, 3]) + const south = lineKey([3, 3], [4, 3]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [west]: 'blank', + [north]: 'line', + [south]: 'line', + }) + }) + + it('White Pearl Rule keeps an axis available when each side still has a turn candidate', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + + it('White Pearl Rule keeps an axis available when only one side can turn', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'blank') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + + it('White Pearl Rule does not force the second white pearl vertical when the shared gap can still turn', () => { + const puzzle = createMasyuPuzzle(9, 10) + addPearl(puzzle, 4, 5, 'white') + addPearl(puzzle, 4, 7, 'white') + markLine(puzzle, lineKey([4, 4], [4, 5]), 'line') + markLine(puzzle, lineKey([4, 5], [4, 6]), 'line') + markLine(puzzle, lineKey([4, 6], [5, 6]), 'blank') + const result = createWhitePearlRule().apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: lineKey([3, 7], [4, 7]), + from: 'unknown', + to: 'line', + }) + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: lineKey([4, 7], [5, 7]), + from: 'unknown', + to: 'line', + }) + }) + + it('White Pearl Rule rejects a horizontal pass-through when both side turns break adjacent pearls', () => { + const puzzle = createMasyuPuzzle(10, 10) + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 5, 3, 'black') + addPearl(puzzle, 4, 4, 'white') + addPearl(puzzle, 4, 5, 'white') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([4, 3], [4, 4])]: 'blank', + [lineKey([4, 4], [4, 5])]: 'blank', + [lineKey([3, 4], [4, 4])]: 'line', + [lineKey([4, 4], [5, 4])]: 'line', + }) + expect(result?.affectedCells).toEqual([cellKey(4, 4)]) + }) + + it('White Pearl Rule treats an adjacent white pearl as a blocked turn side', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + addPearl(puzzle, 2, 3, 'white') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 3], [3, 3])]: 'blank', + [lineKey([3, 3], [4, 3])]: 'blank', + [lineKey([3, 3], [3, 4])]: 'line', + [lineKey([3, 2], [3, 3])]: 'line', + }) + }) + + it('White Pearl Rule keeps an axis available when a nearby black pearl still has candidates', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 2, 'white') + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'blank') + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [lineKey([2, 2], [3, 2])]: 'blank', + [lineKey([3, 2], [3, 3])]: 'line', + [lineKey([3, 1], [3, 2])]: 'line', + }) + }) + + it('White Pearl Rule keeps a turn side available when only one target is black', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'white') + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([4, 2], [4, 3]), 'blank') + markLine(puzzle, lineKey([4, 3], [4, 4]), 'blank') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + + it('White Pearl Rule continues a known line straight and blanks turn candidates', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [north]: 'blank', + [south]: 'blank', + }) + }) + + it('White Pearl Rule does not force a straight line into a degree-2 neighbor', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + + const result = createWhitePearlRule().apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ + kind: 'line', + lineKey: lineKey([1, 1], [1, 2]), + from: 'unknown', + to: 'line', + }) + }) + + it('White Pearl Rule blanks turn candidates when a white pearl already has a straight pair', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [north]: 'blank', [south]: 'blank' }) + }) + + it('White Pearl Rule only highlights pearls that create new line decisions', () => { + const puzzle = createMasyuPuzzle(3, 4) + const unchangedPearl = cellKey(1, 1) + const changedPearl = cellKey(1, 2) + addPearl(puzzle, 1, 1, 'white') + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'blank') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const changedLine = lineKey([1, 2], [1, 3]) + + const result = createWhitePearlRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([changedPearl]) + expect(result?.affectedCells).not.toContain(unchangedPearl) + expect(result?.affectedLines).toEqual([changedLine]) + expectLineDiffs(result?.diffs, { [changedLine]: 'blank' }) + }) + + it('White Pearl Rule blocks the short side from continuing when the other side already runs straight south', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + markLine(puzzle, lineKey([3, 2], [4, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + const northExtension = lineKey([0, 2], [1, 2]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [northExtension]: 'blank' }) + expect(result?.message).toContain('must turn in an adjacent cell') + }) + + it('White Pearl Rule blocks the short side from continuing when the other side already runs straight east', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'line') + markLine(puzzle, lineKey([2, 3], [2, 4]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'blank') + const westExtension = lineKey([2, 0], [2, 1]) + + const result = createWhitePearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [westExtension]: 'blank' }) + }) + + it('White Pearl Rule does not block either side when both sides only reach the pearl', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'blank') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + + it('White Pearl Rule ignores adjacent-turn continuation when the short-side extension leaves the board', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 1, 2, 'white') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [3, 2]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 1], [1, 2]), 'blank') + markLine(puzzle, lineKey([1, 2], [1, 3]), 'blank') + + expect(createWhitePearlRule().apply(puzzle)).toBeNull() + }) + + it('Black Pearl Rule forces the opposite line and its straight extension at the border', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'black') + const south = lineKey([0, 1], [1, 1]) + const extension = lineKey([1, 1], [2, 1]) + + const result = createBlackPearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [south]: 'line', [extension]: 'line' }) + expect(result?.affectedCells).toEqual([cellKey(0, 1)]) + }) + + it('Black Pearl Rule forces the opposite line and extension when one side is already blank', () => { + const puzzle = createMasyuPuzzle(3, 4) + addPearl(puzzle, 1, 1, 'black') + const west = lineKey([1, 1], [1, 0]) + const east = lineKey([1, 1], [1, 2]) + const extension = lineKey([1, 2], [1, 3]) + markLine(puzzle, west, 'blank') + + const result = createBlackPearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'line', [extension]: 'line' }) + }) + + it('Black Pearl Rule does not overwrite already-decided opposite line or extension', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 1, 'black') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 1], [2, 1]), 'line') + + expect(createBlackPearlRule().apply(puzzle)).toBeNull() + }) + + it('Black Pearl Rule only highlights pearls that create new line decisions', () => { + const puzzle = createMasyuPuzzle(5, 5) + const unchangedPearl = cellKey(2, 2) + const changedPearl = cellKey(0, 1) + addPearl(puzzle, 2, 2, 'black') + addPearl(puzzle, 0, 1, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 2], [2, 3]), 'blank') + markLine(puzzle, lineKey([2, 0], [2, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + const changedLine = lineKey([1, 1], [2, 1]) + + const result = createBlackPearlRule().apply(puzzle) + + expect(result?.affectedCells).toEqual([changedPearl]) + expect(result?.affectedCells).not.toContain(unchangedPearl) + expect(result?.affectedLines).toEqual([changedLine]) + expectLineDiffs(result?.diffs, { [changedLine]: 'line' }) + }) + + it('Black Pearl Rule rejects an exit whose second step would leave the board', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 4, 'black') + const east = lineKey([3, 4], [3, 5]) + const west = lineKey([3, 3], [3, 4]) + const extension = lineKey([3, 2], [3, 3]) + + const result = createBlackPearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [west]: 'line', + [extension]: 'line', + }) + }) + + it('Black Pearl Rule rejects an exit whose second step is already blank', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 3, 3, 'black') + const east = lineKey([3, 3], [3, 4]) + const eastExtension = lineKey([3, 4], [3, 5]) + const west = lineKey([3, 2], [3, 3]) + const westExtension = lineKey([3, 1], [3, 2]) + markLine(puzzle, eastExtension, 'blank') + + const result = createBlackPearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [west]: 'line', + [westExtension]: 'line', + }) + }) + + it('Black Pearl Rule turns away from a known line and extends that exit', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + const extension = lineKey([2, 0], [2, 1]) + const east = lineKey([2, 2], [2, 3]) + + const result = createBlackPearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { [east]: 'blank', [extension]: 'line' }) + }) + + it('Black Pearl Rule blanks remaining exits and extends both sides of a known turn', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'black') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([1, 2], [2, 2]), 'line') + const east = lineKey([2, 2], [2, 3]) + const south = lineKey([2, 2], [3, 2]) + const westExtension = lineKey([2, 0], [2, 1]) + const northExtension = lineKey([0, 2], [1, 2]) + + const result = createBlackPearlRule().apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'blank', + [south]: 'blank', + [westExtension]: 'line', + [northExtension]: 'line', + }) + }) +}) diff --git a/src/domain/rules/masyu/tests/registration.test.ts b/src/domain/rules/masyu/tests/registration.test.ts new file mode 100644 index 0000000..0bb523c --- /dev/null +++ b/src/domain/rules/masyu/tests/registration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { runNextRule } from '../../engine' +import { masyuPlugin } from '../../../plugins/masyuPlugin' + +describe('Masyu rule registration', () => { + it('registers Masyu rules in pearl-then-completion order', () => { + expect(masyuPlugin.getRules().map((rule) => rule.name)).toEqual([ + 'White Pearl Rule', + 'Black Pearl Rule', + 'Black Facing Consecutive Whites', + 'Black Diagonal White Pinch', + 'Consecutive White Pearls Straight', + 'White Corridor', + 'Double Black Squeeze', + 'Masyu Tile Color Propagation', + 'Masyu Color-Pearl Propagation', + 'Masyu Color-Line Propagation', + 'Masyu Tile Connectivity Cut Coloring', + 'Masyu Candidate Bridge Line', + 'Prevent Premature Loop', + 'Masyu Empty Cell Premature Loop', + 'Black Pearl Candidate Pruning', + 'White Pearl Candidate Pruning', + 'Adjacent White Pearls LookAhead', + 'Cell Exit Completion', + 'Black Pearl Strong Inference', + 'Masyu Line Component Endpoint Strong Inference', + 'White Pearl Strong Inference', + ]) + }) + + it('applies a line diff on the sample Masyu puzzle', () => { + const puzzle = masyuPlugin.parse('https://puzz.link/p?mashu/5/5/001390360') + const { step } = runNextRule(puzzle, masyuPlugin.getRules(), 1) + + expect(step?.ruleName).toBe('White Pearl Rule') + expect(step?.diffs.some((diff) => diff.kind === 'line')).toBe(true) + }) + + it('registers Masyu color propagation before premature loop prevention', () => { + const rules = masyuPlugin.getRules().map((rule) => rule.id) + + expect(rules).toContain('masyu-tile-color-propagation') + expect(rules).toContain('masyu-color-pearl-propagation') + expect(rules).toContain('masyu-color-line-propagation') + expect(rules).toContain('masyu-tile-connectivity-cut-coloring') + expect(rules).toContain('masyu-candidate-bridge-line') + expect(rules.indexOf('masyu-color-pearl-propagation')).toBe( + rules.indexOf('masyu-tile-color-propagation') + 1, + ) + expect(rules.indexOf('masyu-color-line-propagation')).toBe( + rules.indexOf('masyu-color-pearl-propagation') + 1, + ) + expect(rules.indexOf('masyu-tile-connectivity-cut-coloring')).toBe( + rules.indexOf('masyu-color-line-propagation') + 1, + ) + expect(rules.indexOf('masyu-candidate-bridge-line')).toBe( + rules.indexOf('masyu-tile-connectivity-cut-coloring') + 1, + ) + expect(rules.indexOf('masyu-candidate-bridge-line')).toBeLessThan( + rules.indexOf('masyu-prevent-premature-loop'), + ) + expect(rules.indexOf('masyu-empty-cell-premature-loop')).toBe( + rules.indexOf('masyu-prevent-premature-loop') + 1, + ) + expect(rules.indexOf('masyu-empty-cell-premature-loop')).toBeLessThan( + rules.indexOf('masyu-black-pearl-candidate-pruning'), + ) + expect(rules.indexOf('masyu-white-pearl-candidate-pruning')).toBe( + rules.indexOf('masyu-black-pearl-candidate-pruning') + 1, + ) + expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBe( + rules.indexOf('masyu-white-pearl-candidate-pruning') + 1, + ) + expect(rules.indexOf('masyu-adjacent-white-pearls-lookahead')).toBeLessThan( + rules.indexOf('cell-exit-completion'), + ) + }) + + it('registers bounded strong inference in black-endpoint-white order', () => { + expect(masyuPlugin.getRules().slice(-3).map((rule) => rule.id)).toEqual([ + 'masyu-black-pearl-strong-inference', + 'masyu-line-component-endpoint-strong-inference', + 'masyu-white-pearl-strong-inference', + ]) + }) +}) diff --git a/src/domain/rules/masyu/tests/sharedPrimitives.test.ts b/src/domain/rules/masyu/tests/sharedPrimitives.test.ts new file mode 100644 index 0000000..7f2319c --- /dev/null +++ b/src/domain/rules/masyu/tests/sharedPrimitives.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey, tileKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import { + createMasyuLineDecisionCollector, + createMasyuTileDecisionCollector, +} from '../rules/decisionCollector' +import { + getMasyuBlackPearlKeys, + getMasyuPearlColor, + getMasyuPearlKeys, + getMasyuWhitePearlKeys, +} from '../rules/pearlSelectors' +import { buildMasyuTileParityGraph } from '../rules/tileParity' +import { + buildMasyuCandidateGraph, + findMasyuPrematureLoopClosingLines, + getMasyuRequiredSources, +} from '../rules/lineGraph' +import { markLine, addPearl } from './testUtils' + +describe('Masyu shared helper primitives', () => { + it('collects compatible line and tile decisions without overwriting conflicts', () => { + const puzzle = createMasyuPuzzle(3, 3) + const targetLine = lineKey([1, 1], [1, 2]) + const lineDecisions = createMasyuLineDecisionCollector(puzzle) + + expect(lineDecisions.add(targetLine, 'line')).toBe(true) + expect(lineDecisions.add(targetLine, 'line')).toBe(true) + expect(lineDecisions.add(targetLine, 'blank')).toBe(false) + expect(lineDecisions.diffs()).toEqual([ + { kind: 'line', lineKey: targetLine, from: 'unknown', to: 'line' }, + ]) + + const targetTile = tileKey(1, 1) + const tileDecisions = createMasyuTileDecisionCollector(puzzle) + expect(tileDecisions.add(targetTile, 'green')).toBe(true) + expect(tileDecisions.add(targetTile, 'yellow')).toBe(false) + expect(tileDecisions.diffs()).toEqual([ + { kind: 'tile', tileKey: targetTile, fromFill: null, toFill: 'green' }, + ]) + }) + + it('guards line decisions that would overflow endpoint degree', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + const guarded = createMasyuLineDecisionCollector(puzzle, { + guardLineDegree: true, + }) + + expect(guarded.add(lineKey([1, 1], [1, 2]), 'line')).toBe(false) + expect(guarded.hasChanges()).toBe(false) + }) + + it('selects pearl keys and colors in puzzle order', () => { + const puzzle = createMasyuPuzzle(3, 3) + addPearl(puzzle, 0, 0, 'black') + addPearl(puzzle, 1, 1, 'white') + + expect(getMasyuPearlKeys(puzzle)).toEqual([cellKey(0, 0), cellKey(1, 1)]) + expect(getMasyuBlackPearlKeys(puzzle)).toEqual([cellKey(0, 0)]) + expect(getMasyuWhitePearlKeys(puzzle)).toEqual([cellKey(1, 1)]) + expect(getMasyuPearlColor(puzzle, cellKey(1, 1))).toBe('white') + }) + + it('shares line graph vocabulary for premature closures and required sources', () => { + const puzzle = createMasyuPuzzle(4, 4) + addPearl(puzzle, 2, 2, 'white') + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 1], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([3, 2], [3, 3]), 'line') + const closingLine = lineKey([0, 0], [1, 0]) + + expect(findMasyuPrematureLoopClosingLines(puzzle)).toEqual([closingLine]) + expect( + getMasyuRequiredSources(puzzle, buildMasyuCandidateGraph(puzzle)), + ).toEqual( + new Set([ + cellKey(2, 2), + cellKey(0, 0), + cellKey(0, 1), + cellKey(1, 1), + cellKey(1, 0), + cellKey(3, 2), + cellKey(3, 3), + ]), + ) + }) + + it('builds tile parity from boundary, line, blank, and fixed color anchors', () => { + const puzzle = createMasyuPuzzle(3, 3) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'blank') + puzzle.tiles[tileKey(1, 1)] = { fill: 'green' } + + const parity = buildMasyuTileParityGraph(puzzle) + + expect(parity.getInferredColor(tileKey(0, 1))).toBe('yellow') + expect(parity.getInferredColor(tileKey(1, 1))).toBe('green') + expect(parity.getInferredColor(tileKey(2, 1))).toBe('green') + expect(parity.firstConflict).toBeNull() + }) +}) diff --git a/src/domain/rules/masyu/tests/testUtils.ts b/src/domain/rules/masyu/tests/testUtils.ts new file mode 100644 index 0000000..02edb71 --- /dev/null +++ b/src/domain/rules/masyu/tests/testUtils.ts @@ -0,0 +1,58 @@ +import { expect } from 'vitest' +import { cellKey, lineKey, tileKey } from '../../../ir/keys' +import type { LineMark, PuzzleIR } from '../../../ir/types' +import type { RuleDiff } from '../../types' + +export const markLine = ( + puzzle: PuzzleIR, + key: string, + mark: LineMark, +): void => { + puzzle.lines[key] = { ...puzzle.lines[key], mark } +} + +export const addPearl = ( + puzzle: PuzzleIR, + row: number, + col: number, + color: 'white' | 'black', +): void => { + puzzle.cells[cellKey(row, col)] = { clue: { kind: 'pearl', color } } +} + +export const fillAllTiles = ( + puzzle: PuzzleIR, + fill: 'green' | 'yellow', +): void => { + for (let row = 0; row <= puzzle.rows; row += 1) { + for (let col = 0; col <= puzzle.cols; col += 1) { + puzzle.tiles[tileKey(row, col)] = { fill } + } + } +} + +export const getLineDegree = ( + puzzle: PuzzleIR, + row: number, + col: number, +): number => + [ + row > 0 ? lineKey([row - 1, col], [row, col]) : null, + row < puzzle.rows - 1 ? lineKey([row, col], [row + 1, col]) : null, + col > 0 ? lineKey([row, col - 1], [row, col]) : null, + col < puzzle.cols - 1 ? lineKey([row, col], [row, col + 1]) : null, + ].filter((key) => key !== null && puzzle.lines[key]?.mark === 'line').length + +export const expectLineDiffs = ( + diffs: RuleDiff[] | undefined, + expected: Record, +): void => { + expect( + Object.fromEntries( + (diffs ?? []).map((diff) => [ + diff.kind === 'line' ? diff.lineKey : '', + diff.kind === 'line' ? diff.to : '', + ]), + ), + ).toEqual(expected) +} diff --git a/src/domain/rules/masyu/tests/trial.test.ts b/src/domain/rules/masyu/tests/trial.test.ts new file mode 100644 index 0000000..b237b03 --- /dev/null +++ b/src/domain/rules/masyu/tests/trial.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import type { Rule } from '../../types' +import { + findMasyuHardContradictionReason, + runMasyuTrialUntilFixpoint, +} from '../rules/trial' +import { markLine } from './testUtils' + +describe('Masyu hard contradiction detection', () => { + it('treats a degree-1 empty cell as closed when every unknown exit would overflow its other endpoint', () => { + const puzzle = createMasyuPuzzle(3, 4) + markLine(puzzle, lineKey([0, 0], [0, 1]), 'line') + markLine(puzzle, lineKey([0, 2], [0, 3]), 'line') + markLine(puzzle, lineKey([0, 2], [1, 2]), 'line') + markLine(puzzle, lineKey([1, 0], [1, 1]), 'line') + markLine(puzzle, lineKey([1, 0], [2, 0]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 2]), 'line') + markLine(puzzle, lineKey([2, 1], [2, 0]), 'line') + + const reason = findMasyuHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('cell-degree') + expect(reason?.message).toContain('only one line segment') + }) + + it('records trial steps and structured contradiction focus', () => { + const puzzle = createMasyuPuzzle(2, 3) + const target = lineKey([0, 0], [0, 1]) + const overflowA = lineKey([0, 1], [0, 2]) + const overflowB = lineKey([0, 1], [1, 1]) + const rule: Rule = { + id: 'test-trial-trace', + name: 'Test Trial Trace', + apply: () => ({ + message: 'Create a focused contradiction', + diffs: [ + { kind: 'line', lineKey: target, from: 'unknown', to: 'line' }, + { kind: 'line', lineKey: overflowA, from: 'unknown', to: 'line' }, + { kind: 'line', lineKey: overflowB, from: 'unknown', to: 'line' }, + ], + affectedCells: [cellKey(0, 1)], + affectedLines: [target, overflowA, overflowB], + }), + } + + const result = runMasyuTrialUntilFixpoint( + puzzle, + [rule], + 1, + Date.now() + 1000, + ) + + expect(result.contradictionReason).toMatchObject({ + kind: 'cell-degree', + cells: [cellKey(0, 1)], + }) + expect(result.traceSteps).toMatchObject([ + { + ruleId: 'test-trial-trace', + affectedLines: [target, overflowA, overflowB], + }, + ]) + }) +}) diff --git a/src/domain/rules/masyu/tests/whitePearlStrongInference.test.ts b/src/domain/rules/masyu/tests/whitePearlStrongInference.test.ts new file mode 100644 index 0000000..8d87243 --- /dev/null +++ b/src/domain/rules/masyu/tests/whitePearlStrongInference.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, lineKey, tileKey } from '../../../ir/keys' +import { createMasyuPuzzle } from '../../../ir/masyu' +import type { Rule } from '../../types' +import { createWhitePearlStrongInferenceRule } from '../rules/whitePearlStrongInference' +import { markLine, addPearl, expectLineDiffs } from './testUtils' + +describe('Masyu white pearl strong inference', () => { + it('forces the opposite white pearl axis when one axis causes a tile-color contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + puzzle.tiles[tileKey(2, 3)] = { fill: 'yellow' } + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const east = lineKey([2, 2], [2, 3]) + const west = lineKey([2, 1], [2, 2]) + + const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [west]: 'line', + [north]: 'blank', + [south]: 'blank', + }) + expect(result?.affectedCells).toEqual([cellKey(2, 2)]) + expect(result?.affectedLines).toEqual([east, west, north, south]) + expect(result?.message).toContain('tile-color contradiction') + expect(result?.message).toContain('must go horizontal') + expect(result?.inferenceDetails?.branches[0]).toMatchObject({ + role: 'trial', + status: 'contradiction', + contradiction: { kind: 'tile-color' }, + }) + expect(result?.inferenceDetails?.branches[1]).toMatchObject({ + role: 'forced-conclusion', + status: 'forced', + initialDiffs: result?.diffs, + }) + }) + + it('uses deterministic downstream rules to find a white-axis contradiction', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + const north = lineKey([1, 2], [2, 2]) + const south = lineKey([2, 2], [3, 2]) + const east = lineKey([2, 2], [2, 3]) + const west = lineKey([2, 1], [2, 2]) + const westOfNeighbor = lineKey([1, 1], [1, 2]) + const eastOfNeighbor = lineKey([1, 2], [1, 3]) + const downstreamRule: Rule = { + id: 'test-white-downstream-degree', + name: 'Test White Downstream Degree', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[westOfNeighbor]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Force a downstream contradiction', + diffs: [ + { + kind: 'line', + lineKey: westOfNeighbor, + from: 'unknown', + to: 'line', + }, + { + kind: 'line', + lineKey: eastOfNeighbor, + from: 'unknown', + to: 'line', + }, + ], + affectedCells: [], + affectedLines: [westOfNeighbor, eastOfNeighbor], + } + }, + } + + const result = createWhitePearlStrongInferenceRule(() => [ + downstreamRule, + ]).apply(puzzle) + + expectLineDiffs(result?.diffs, { + [east]: 'line', + [west]: 'line', + [north]: 'blank', + [south]: 'blank', + }) + expect(result?.message).toContain('after 1 step') + }) + + it('does not copy white trial progress back into the real puzzle', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + const north = lineKey([1, 2], [2, 2]) + const unrelated = lineKey([4, 3], [4, 4]) + const harmlessRule: Rule = { + id: 'test-white-harmless-trial-progress', + name: 'Test White Harmless Trial Progress', + apply: (trial) => { + if ((trial.lines[north]?.mark ?? 'unknown') !== 'line') { + return null + } + if ((trial.lines[unrelated]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'Harmless trial-only progress', + diffs: [ + { kind: 'line', lineKey: unrelated, from: 'unknown', to: 'line' }, + ], + affectedCells: [], + affectedLines: [unrelated], + } + }, + } + + const result = createWhitePearlStrongInferenceRule(() => [harmlessRule], { + maxTrialSteps: 1, + }).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[unrelated]?.mark).toBe('unknown') + }) + + it('returns null when the white trial budget times out', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + + const result = createWhitePearlStrongInferenceRule(() => [], { + maxMs: -1, + }).apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not overwrite decided lines when forcing the opposite white axis', () => { + const puzzle = createMasyuPuzzle(5, 5) + addPearl(puzzle, 2, 2, 'white') + puzzle.tiles[tileKey(2, 2)] = { fill: 'yellow' } + puzzle.tiles[tileKey(2, 3)] = { fill: 'yellow' } + const east = lineKey([2, 2], [2, 3]) + markLine(puzzle, east, 'blank') + + const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) + + expect(result).toBeNull() + expect(puzzle.lines[east]?.mark).toBe('blank') + }) + + it('tries white pearls with denser nearby pearl context first', () => { + const puzzle = createMasyuPuzzle(6, 6) + addPearl(puzzle, 1, 1, 'white') + puzzle.tiles[tileKey(1, 1)] = { fill: 'yellow' } + puzzle.tiles[tileKey(1, 2)] = { fill: 'yellow' } + addPearl(puzzle, 4, 4, 'white') + addPearl(puzzle, 3, 3, 'black') + addPearl(puzzle, 3, 4, 'white') + puzzle.tiles[tileKey(4, 4)] = { fill: 'yellow' } + puzzle.tiles[tileKey(4, 5)] = { fill: 'yellow' } + const prioritizedEast = lineKey([4, 4], [4, 5]) + const prioritizedWest = lineKey([4, 3], [4, 4]) + const prioritizedNorth = lineKey([3, 4], [4, 4]) + const prioritizedSouth = lineKey([4, 4], [5, 4]) + + const result = createWhitePearlStrongInferenceRule(() => []).apply(puzzle) + + expect(result?.affectedCells).toEqual([cellKey(4, 4)]) + expectLineDiffs(result?.diffs, { + [prioritizedEast]: 'line', + [prioritizedWest]: 'line', + [prioritizedNorth]: 'blank', + [prioritizedSouth]: 'blank', + }) + }) +}) diff --git a/src/domain/rules/slither/rules.test.ts b/src/domain/rules/slither/rules.test.ts deleted file mode 100644 index e0a1525..0000000 --- a/src/domain/rules/slither/rules.test.ts +++ /dev/null @@ -1,3307 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { decodeSlitherFromPuzzlink } from '../../parsers/puzzlink' -import { - cellKey, - edgeKey, - getCellEdgeKeys, - getCornerEdgeKeys, - getVertexIncidentEdges, - parseEdgeKey, - sectorKey, - vertexKey, -} from '../../ir/keys' -import { clonePuzzle } from '../../ir/normalize' -import { createSlitherPuzzle } from '../../ir/slither' -import { - SECTOR_MASK_ALL, - SECTOR_MASK_NOT_0, - SECTOR_MASK_NOT_1, - SECTOR_MASK_NOT_2, - SECTOR_MASK_ONLY_0, - SECTOR_MASK_ONLY_1, - SECTOR_MASK_ONLY_2, - type PuzzleIR, -} from '../../ir/types' -import { runNextRule } from '../engine' -import type { Rule } from '../types' -import { deterministicSlitherRules, slitherRules } from './rules' -import { createColorAssumptionInferenceRule } from './rules/colorAssumptionInference' -import { createSectorParityInferenceRule } from './rules/sectorParityInference' -import { createStrongInferenceRule } from './rules/strongInference' -import { findHardContradictionReason, runTrialUntilFixpoint } from './rules/trial' - -const setClue = (puzzle: PuzzleIR, row: number, col: number, value: number): void => { - puzzle.cells[cellKey(row, col)] = { - clue: { kind: 'number', value }, - } -} - -const getEdgeDiffKeys = (result: ReturnType<(typeof slitherRules)[number]['apply']>): string[] => - result?.diffs.flatMap((d) => (d.kind === 'edge' ? [d.edgeKey] : [])) ?? [] - -describe('slither deterministic rule order', () => { - it('prioritizes exact sector edge propagation before color and sector inference rules', () => { - const edgePropagationIdx = deterministicSlitherRules.findIndex( - (rule) => rule.id === 'sector-constraint-edge-propagation', - ) - const vertexDegreeIdx = deterministicSlitherRules.findIndex((rule) => rule.id === 'vertex-degree') - const colorOutsideIdx = deterministicSlitherRules.findIndex( - (rule) => rule.id === 'color-outside-seeding', - ) - const sectorInferenceIdx = deterministicSlitherRules.findIndex( - (rule) => rule.id === 'sector-inference', - ) - - expect(edgePropagationIdx).toBeGreaterThan(vertexDegreeIdx) - expect(edgePropagationIdx).toBeLessThan(colorOutsideIdx) - expect(edgePropagationIdx).toBeLessThan(sectorInferenceIdx) - }) -}) - -describe('slither contiguous 3-run boundaries rule', () => { - const threeRunRule = slitherRules.find((rule) => rule.id === 'contiguous-three-run-boundaries') - if (!threeRunRule) { - throw new Error('Expected contiguous-three-run-boundaries rule') - } - - it('forces all vertical run boundaries for a horizontal 3-run', () => { - const puzzle = createSlitherPuzzle(4, 5) - setClue(puzzle, 1, 1, 3) - setClue(puzzle, 1, 2, 3) - setClue(puzzle, 1, 3, 3) - - const result = threeRunRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.message).toContain('Contiguous 3-run') - expect(result?.affectedCells).toEqual(['1,1', '1,2', '1,3']) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([1, 1], [2, 1]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([1, 2], [2, 2]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([1, 3], [2, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([1, 4], [2, 4]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([0, 2], [1, 2]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 2], [3, 2]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([0, 3], [1, 3]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 3], [3, 3]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('forces all horizontal run boundaries for a vertical 3-run', () => { - const puzzle = createSlitherPuzzle(5, 4) - setClue(puzzle, 1, 2, 3) - setClue(puzzle, 2, 2, 3) - setClue(puzzle, 3, 2, 3) - - const result = threeRunRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.message).toContain('Contiguous 3-run') - expect(result?.affectedCells).toEqual(['1,2', '2,2', '3,2']) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([1, 2], [1, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([2, 2], [2, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([3, 2], [3, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([4, 2], [4, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([2, 1], [2, 2]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 3], [2, 4]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([3, 1], [3, 2]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([3, 3], [3, 4]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('does not apply for an isolated single clue-3 cell', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 3) - - const result = threeRunRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('emits diffs only for unknown edges within a matched run', () => { - const puzzle = createSlitherPuzzle(3, 4) - setClue(puzzle, 0, 1, 3) - setClue(puzzle, 0, 2, 3) - - const alreadyLine = edgeKey([0, 1], [1, 1]) - const blocked = edgeKey([0, 2], [1, 2]) - const unknownEdge = edgeKey([0, 3], [1, 3]) - puzzle.edges[alreadyLine].mark = 'line' - puzzle.edges[blocked].mark = 'blank' - - const result = threeRunRule.apply(puzzle) - - expect(result).not.toBeNull() - const extensionBlank = edgeKey([1, 2], [2, 2]) - expect(result?.affectedCells).toEqual(['0,1', '0,2']) - expect(getEdgeDiffKeys(result)).toEqual([unknownEdge, extensionBlank]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: unknownEdge, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: extensionBlank, from: 'unknown', to: 'blank' }, - ]) - }) - - it('only emits in-bounds extension blanks near board edge', () => { - const puzzle = createSlitherPuzzle(5, 4) - setClue(puzzle, 1, 0, 3) - setClue(puzzle, 2, 0, 3) - setClue(puzzle, 3, 0, 3) - - const result = threeRunRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'edge', - edgeKey: edgeKey([2, 1], [2, 2]), - from: 'unknown', - to: 'blank', - }) - expect(result?.diffs).toContainEqual({ - kind: 'edge', - edgeKey: edgeKey([3, 1], [3, 2]), - from: 'unknown', - to: 'blank', - }) - const blankEdgeKeys = - result?.diffs.flatMap((d) => (d.kind === 'edge' && d.to === 'blank' ? [d.edgeKey] : [])) ?? [] - expect(blankEdgeKeys).toEqual([edgeKey([2, 1], [2, 2]), edgeKey([3, 1], [3, 2])]) - }) - - it('appears on provided 6x6 puzzle and emits both horizontal/vertical extension blanks', () => { - let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/6/6/mdidi833dp') - let sawVerticalBlank = false - let sawHorizontalBlank = false - - for (let stepNumber = 1; stepNumber <= 500; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - if (step.ruleId !== 'contiguous-three-run-boundaries') { - current = nextPuzzle - continue - } - - for (const diff of step.diffs) { - if (diff.kind !== 'edge' || diff.to !== 'blank') { - continue - } - const [a, b] = parseEdgeKey(diff.edgeKey) - if (a[0] !== b[0]) { - sawVerticalBlank = true - } else { - sawHorizontalBlank = true - } - } - - if (sawVerticalBlank && sawHorizontalBlank) { - break - } - current = nextPuzzle - } - - expect(sawVerticalBlank).toBe(true) - expect(sawHorizontalBlank).toBe(true) - }) -}) - -describe('slither diagonal adjacent 3 outer corners rule', () => { - const diagonalRule = slitherRules.find((rule) => rule.id === 'diagonal-adjacent-three-outer-corners') - if (!diagonalRule) { - throw new Error('Expected diagonal-adjacent-three-outer-corners rule') - } - - it('forces outer-corner edges for main diagonal adjacent 3s', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 0, 0, 3) - setClue(puzzle, 1, 1, 3) - - const result = diagonalRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.message).toContain('Diagonal adjacent 3s force their outside corner edges') - expect(result?.affectedCells).toEqual(['0,0', '1,1']) - expect(getEdgeDiffKeys(result)).toEqual([ - edgeKey([0, 0], [1, 0]), - edgeKey([0, 0], [0, 1]), - edgeKey([1, 2], [2, 2]), - edgeKey([2, 1], [2, 2]), - ]) - }) - - it('forces outer-corner edges for anti diagonal adjacent 3s', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 0, 1, 3) - setClue(puzzle, 1, 0, 3) - - const result = diagonalRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.message).toContain('Diagonal adjacent 3s force their outside corner edges') - expect(result?.affectedCells).toEqual(['0,1', '1,0']) - expect(getEdgeDiffKeys(result)).toEqual([ - edgeKey([0, 1], [0, 2]), - edgeKey([0, 2], [1, 2]), - edgeKey([1, 0], [2, 0]), - edgeKey([2, 0], [2, 1]), - ]) - }) - - it('applies both diagonals in one step when a 2x2 block is all 3s', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 0, 0, 3) - setClue(puzzle, 0, 1, 3) - setClue(puzzle, 1, 0, 3) - setClue(puzzle, 1, 1, 3) - - const result = diagonalRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual(['0,0', '1,1', '0,1', '1,0']) - expect(getEdgeDiffKeys(result)).toEqual([ - edgeKey([0, 0], [1, 0]), - edgeKey([0, 0], [0, 1]), - edgeKey([1, 2], [2, 2]), - edgeKey([2, 1], [2, 2]), - edgeKey([0, 1], [0, 2]), - edgeKey([0, 2], [1, 2]), - edgeKey([1, 0], [2, 0]), - edgeKey([2, 0], [2, 1]), - ]) - expect( - result?.diffs.every((d) => d.kind === 'edge' && d.from === 'unknown' && d.to === 'line'), - ).toBe(true) - }) - - it('does not apply when 3 clues are not diagonally adjacent', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 0, 0, 3) - setClue(puzzle, 2, 2, 3) - - const result = diagonalRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('emits diffs only for unknown edges when diagonal pattern is matched', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 0, 0, 3) - setClue(puzzle, 1, 1, 3) - - const alreadyLine = edgeKey([0, 0], [1, 0]) - const alreadyBlank = edgeKey([0, 0], [0, 1]) - const unknownA = edgeKey([1, 2], [2, 2]) - const unknownB = edgeKey([2, 1], [2, 2]) - puzzle.edges[alreadyLine].mark = 'line' - puzzle.edges[alreadyBlank].mark = 'blank' - - const result = diagonalRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual(['0,0', '1,1']) - expect(getEdgeDiffKeys(result)).toEqual([unknownA, unknownB]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: unknownA, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: unknownB, from: 'unknown', to: 'line' }, - ]) - }) -}) - -describe('slither adjacent 2-3 opposite-cross rule', () => { - const adjacentRule = slitherRules.find((rule) => rule.id === 'adjacent-two-three-opposite-cross') - if (!adjacentRule) { - throw new Error('Expected adjacent-two-three-opposite-cross rule') - } - - it('forces the 3 opposite edge and shared-edge extensions for a horizontal 2-3 pair', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 1, 1, 2) - setClue(puzzle, 1, 2, 3) - puzzle.edges[edgeKey([1, 1], [2, 1])].mark = 'blank' - - const result = adjacentRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual([cellKey(1, 1), cellKey(1, 2)]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([1, 3], [2, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([0, 2], [1, 2]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 2], [3, 2]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('supports the mirrored horizontal 3-2 order', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 1, 1, 3) - setClue(puzzle, 1, 2, 2) - puzzle.edges[edgeKey([1, 3], [2, 3])].mark = 'blank' - - const result = adjacentRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual([cellKey(1, 2), cellKey(1, 1)]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([1, 1], [2, 1]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([0, 2], [1, 2]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 2], [3, 2]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('forces the 3 opposite edge and shared-edge extensions for a vertical 2-3 pair', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 1, 1, 2) - setClue(puzzle, 2, 1, 3) - puzzle.edges[edgeKey([1, 1], [1, 2])].mark = 'blank' - - const result = adjacentRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual([cellKey(1, 1), cellKey(2, 1)]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([3, 1], [3, 2]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([2, 0], [2, 1]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 2], [2, 3]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('supports the mirrored vertical 3-2 order', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 1, 1, 3) - setClue(puzzle, 2, 1, 2) - puzzle.edges[edgeKey([3, 1], [3, 2])].mark = 'blank' - - const result = adjacentRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual([cellKey(2, 1), cellKey(1, 1)]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([1, 1], [1, 2]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([2, 0], [2, 1]), from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: edgeKey([2, 2], [2, 3]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('only emits in-bounds extension blanks at the boundary', () => { - const puzzle = createSlitherPuzzle(3, 4) - setClue(puzzle, 0, 1, 2) - setClue(puzzle, 0, 2, 3) - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' - - const result = adjacentRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([0, 3], [1, 3]), from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: edgeKey([1, 2], [2, 2]), from: 'unknown', to: 'blank' }, - ]) - }) - - it('does not apply when the 2 opposite edge is not explicitly blank', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 1, 1, 2) - setClue(puzzle, 1, 2, 3) - - const result = adjacentRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('emits diffs only for unknown target edges', () => { - const puzzle = createSlitherPuzzle(4, 4) - setClue(puzzle, 1, 1, 2) - setClue(puzzle, 1, 2, 3) - puzzle.edges[edgeKey([1, 1], [2, 1])].mark = 'blank' - puzzle.edges[edgeKey([1, 3], [2, 3])].mark = 'line' - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' - - const result = adjacentRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([2, 2], [3, 2]), from: 'unknown', to: 'blank' }, - ]) - }) -}) - -describe('slither cell clue completion rule', () => { - const cellCountRule = slitherRules.find((rule) => rule.id === 'cell-count-completion') - if (!cellCountRule) { - throw new Error('Expected cell-count-completion rule') - } - - it('fills remaining unknown edges as blank when clue already has enough lines', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 1) - const [top, bottom, left, right] = getCellEdgeKeys(0, 0) - puzzle.edges[top].mark = 'line' - - const result = cellCountRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual([cellKey(0, 0)]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, - ]) - }) - - it('fills remaining unknown edges as line when all unknowns are required by clue', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 3) - const [top, bottom, left, right] = getCellEdgeKeys(0, 0) - puzzle.edges[top].mark = 'blank' - - const result = cellCountRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedCells).toEqual([cellKey(0, 0)]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }, - ]) - }) -}) - -describe('slither color-edge propagation rule', () => { - const colorRule = slitherRules.find((rule) => rule.id === 'color-edge-propagation') - if (!colorRule) { - throw new Error('Expected color-edge-propagation rule') - } - - it('marks edge blank when two adjacent cells have same color', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - const between = edgeKey([0, 1], [1, 1]) - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'edge', - edgeKey: between, - from: 'unknown', - to: 'blank', - }) - }) - - it('marks edge line when two adjacent cells have different colors', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - const between = edgeKey([0, 1], [1, 1]) - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'edge', - edgeKey: between, - from: 'unknown', - to: 'line', - }) - }) - - it('marks top boundary edge line when the boundary cell is green', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - const top = edgeKey([0, 1], [0, 2]) - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'edge', - edgeKey: top, - from: 'unknown', - to: 'line', - }) - }) - - it('marks both outer boundary edges line when a corner cell is green', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - const top = edgeKey([0, 0], [0, 1]) - const left = edgeKey([0, 0], [1, 0]) - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, - ]), - ) - }) - - it('marks boundary edges blank when the boundary cell is yellow', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } - const top = edgeKey([0, 0], [0, 1]) - const left = edgeKey([0, 0], [1, 0]) - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'edge', edgeKey: top, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' }, - ]), - ) - }) - - it('does not emit a phantom cell diff across an already decided boundary edge', () => { - const puzzle = createSlitherPuzzle(2, 2) - const top = edgeKey([0, 0], [0, 1]) - puzzle.edges[top].mark = 'line' - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - - const result = colorRule.apply(puzzle) - - expect(result?.diffs.some((diff) => diff.kind === 'cell' && diff.cellKey === undefined)).not.toBe(true) - }) - - it('infers opposite color across a line edge', () => { - const puzzle = createSlitherPuzzle(2, 2) - const between = edgeKey([0, 1], [1, 1]) - puzzle.edges[between].mark = 'line' - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('infers same color across a blank edge', () => { - const puzzle = createSlitherPuzzle(2, 2) - const between = edgeKey([0, 1], [1, 1]) - puzzle.edges[between].mark = 'blank' - puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } - - const result = colorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: null, - toFill: 'yellow', - }) - }) -}) - -describe('slither color outside seeding rule', () => { - const outsideRule = slitherRules.find((rule) => rule.id === 'color-outside-seeding') - if (!outsideRule) { - throw new Error('Expected color-outside-seeding rule') - } - - it('marks boundary-adjacent cell yellow when boundary edge is blank', () => { - const puzzle = createSlitherPuzzle(2, 2) - const topLeftTop = edgeKey([0, 0], [0, 1]) - puzzle.edges[topLeftTop].mark = 'blank' - - const result = outsideRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 0), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('marks boundary-adjacent cell green when boundary edge is line', () => { - const puzzle = createSlitherPuzzle(2, 2) - const topLeftTop = edgeKey([0, 0], [0, 1]) - puzzle.edges[topLeftTop].mark = 'line' - - const result = outsideRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 0), - fromFill: null, - toFill: 'green', - }) - }) - - it('colors a whole boundary-anchored parity component in one application', () => { - const puzzle = createSlitherPuzzle(2, 3) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' - - const result = outsideRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'yellow' }, - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }, - ]), - ) - }) - - it('does not color an unanchored parity component', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = outsideRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('uses an existing colored cell as a parity component anchor', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = outsideRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }, - ]) - }) - - it('does not color a component with conflicting anchors', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - - const result = outsideRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('colors multiple anchored components independently', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' - puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line' - - const result = outsideRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'yellow' }, - { kind: 'cell', cellKey: cellKey(1, 1), fromFill: null, toFill: 'green' }, - ]), - ) - }) -}) - -describe('slither color clue propagation rule', () => { - const clueColorRule = slitherRules.find((rule) => rule.id === 'color-clue-propagation') - if (!clueColorRule) { - throw new Error('Expected color-clue-propagation rule') - } - - it('colors numbered cell green when clue is less than innercnt', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 1) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - - const result = clueColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('propagates yellow neighbors when yellow numbered cell has clue equal to innercnt', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 1) - puzzle.cells[cellKey(1, 1)] = { ...puzzle.cells[cellKey(1, 1)], fill: 'yellow' } - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - - const result = clueColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'yellow', - }) - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 0), - fromFill: null, - toFill: 'yellow', - }) - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 2), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('propagates green neighbors from two yellow neighbors around a clue 2 without needing the clue cell color', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } - - const result = clueColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'green', - }) - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 2), - fromFill: null, - toFill: 'green', - }) - }) - - it('propagates yellow neighbors from two green neighbors around a clue 2 without needing the clue cell color', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - - const result = clueColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'yellow', - }) - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 2), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('counts out-of-bounds directions as yellow when propagating from a corner clue 2', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 2) - - const result = clueColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 0), - fromFill: null, - toFill: 'green', - }) - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('counts out-of-bounds directions as yellow when coloring a corner numbered cell', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 1) - puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - - const result = clueColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 0), - fromFill: null, - toFill: 'green', - }) - }) -}) - -describe('slither color orthogonal consensus propagation rule', () => { - const orthogonalColorRule = slitherRules.find((rule) => rule.id === 'color-orthogonal-consensus-propagation') - if (!orthogonalColorRule) { - throw new Error('Expected color-orthogonal-consensus-propagation rule') - } - - it('colors an interior unknown cell green when four orthogonal neighbors are green', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(2, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - puzzle.cells[cellKey(1, 2)] = { fill: 'green' } - - const result = orthogonalColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('colors an interior unknown cell yellow when four orthogonal neighbors are yellow', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - puzzle.cells[cellKey(2, 1)] = { fill: 'yellow' } - puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } - puzzle.cells[cellKey(1, 2)] = { fill: 'yellow' } - - const result = orthogonalColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(1, 1), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('treats out-of-bounds orthogonals as yellow for boundary inference', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } - - const result = orthogonalColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 0), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('does not apply when an in-bounds orthogonal neighbor is unknown', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(2, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - - const result = orthogonalColorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not apply when orthogonal neighbors are mixed colors', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(2, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - puzzle.cells[cellKey(1, 2)] = { fill: 'yellow' } - - const result = orthogonalColorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not overwrite an already colored cell', () => { - const puzzle = createSlitherPuzzle(1, 1) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - - const result = orthogonalColorRule.apply(puzzle) - - expect(result).toBeNull() - }) -}) - -describe('slither inside reachability coloring rule', () => { - const reachabilityRule = slitherRules.find((rule) => rule.id === 'inside-reachability-coloring') - if (!reachabilityRule) { - throw new Error('Expected inside-reachability-coloring rule') - } - - it('colors an unreachable unknown non-3 cell yellow', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = reachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }, - ]) - }) - - it('does not color reachable unknown cells across unknown or blank edges', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' - - const result = reachabilityRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not cross a line edge', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = reachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }, - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'yellow' }, - ]), - ) - }) - - it('does not traverse through existing yellow cells', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - - const result = reachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'yellow' }, - ]) - }) - - it('does not traverse into clue-3 cells and does not color clue-3 cells yellow', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - setClue(puzzle, 0, 1, 3) - - const result = reachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'yellow' }, - ]) - expect(result?.diffs).not.toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('returns null when there are no known green source cells', () => { - const puzzle = createSlitherPuzzle(2, 2) - - const result = reachabilityRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('floods from multiple green source components', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 2)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = reachabilityRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('appears on the provided 19x10 puzzle within the normal solve limit', () => { - let current = decodeSlitherFromPuzzlink( - 'https://puzz.link/p?slither/19/10/y13c22d32c1186b8c8b8631d31b13c32czx32c22b21d3376d8d8c7612d32b23b31cw', - ) - let sawReachabilityColoring = false - - for (let stepNumber = 1; stepNumber <= 100; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - if ( - step.ruleId === 'inside-reachability-coloring' && - step.diffs.some((diff) => diff.kind === 'cell' && diff.toFill === 'yellow') - ) { - sawReachabilityColoring = true - break - } - current = nextPuzzle - } - - expect(sawReachabilityColoring).toBe(true) - }) -}) - -describe('slither outside reachability coloring rule', () => { - const outsideReachabilityRule = slitherRules.find((rule) => rule.id === 'outside-reachability-coloring') - if (!outsideReachabilityRule) { - throw new Error('Expected outside-reachability-coloring rule') - } - - it('colors a fully line-enclosed unknown cell green', () => { - const puzzle = createSlitherPuzzle(1, 1) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, - ]) - }) - - it('does not color a boundary cell reachable through an unknown outside edge', () => { - const puzzle = createSlitherPuzzle(1, 1) - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not color a boundary cell reachable through a blank outside edge', () => { - const puzzle = createSlitherPuzzle(1, 1) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('traverses unknown and blank edges but does not cross a line edge', () => { - const puzzle = createSlitherPuzzle(1, 4) - for (let col = 0; col < 3; col += 1) { - puzzle.edges[edgeKey([0, col], [0, col + 1])].mark = 'line' - puzzle.edges[edgeKey([1, col], [1, col + 1])].mark = 'line' - } - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' - puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line' - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }, - ]) - }) - - it('treats existing green cells as traversal blockers without overwriting them', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, - ]) - expect(result?.diffs).not.toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: 'green', - toFill: 'green', - }) - }) - - it('does not traverse into clue-3 cells and does not color clue-3 cells green', () => { - const puzzle = createSlitherPuzzle(1, 3) - setClue(puzzle, 0, 1, 3) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, - ]) - expect(result?.diffs).not.toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('uses existing yellow cells as outside reachability sources', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - for (let col = 0; col < 3; col += 1) { - puzzle.edges[edgeKey([0, col], [0, col + 1])].mark = 'line' - puzzle.edges[edgeKey([1, col], [1, col + 1])].mark = 'line' - } - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line' - - const result = outsideReachabilityRule.apply(puzzle) - - expect(result).toBeNull() - }) -}) - -describe('slither color connectivity cut coloring rule', () => { - const cutColorRule = slitherRules.find((rule) => rule.id === 'color-connectivity-cut-coloring') - if (!cutColorRule) { - throw new Error('Expected color-connectivity-cut-coloring rule') - } - - it('colors an unknown articulation cell green between two green components', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 2)] = { fill: 'green' } - - const result = cutColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, - ]) - }) - - it('does not apply when there is only one green source component', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - - const result = cutColorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not color across a line-separated green component', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 2)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line' - - const result = cutColorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('treats an unknown clue-3 cell as a colorable connectivity candidate', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 2)] = { fill: 'green' } - setClue(puzzle, 0, 1, 3) - - const result = cutColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, - ]) - }) - - it('colors every unknown cell inside a blank-compressed green bottleneck', () => { - const puzzle = createSlitherPuzzle(1, 4) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 3)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' - - const result = cutColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }, - ]) - }) - - it('colors an outside-to-yellow bottleneck yellow', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(1, 1)] = { fill: 'yellow' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - puzzle.cells[cellKey(1, 2)] = { fill: 'green' } - puzzle.cells[cellKey(2, 1)] = { fill: 'green' } - - const result = cutColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }, - ]) - }) - - it('colors cells unreachable from green sources yellow', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = cutColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }, - { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'yellow' }, - ]) - }) - - it('colors cells unreachable from the exterior green without an existing yellow source', () => { - const puzzle = createSlitherPuzzle(1, 1) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = cutColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, - ]) - }) - - it('keeps unknown edges passable for connectivity reachability', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - - const result = cutColorRule.apply(puzzle) - - expect(result).toBeNull() - }) -}) - -describe('slither color sector-mask propagation rule', () => { - const sectorColorRule = slitherRules.find((rule) => rule.id === 'color-sector-mask-propagation') - if (!sectorColorRule) { - throw new Error('Expected color-sector-mask-propagation rule') - } - - it('infers same color from notOne sector when one adjacent cell color is known', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 - puzzle.cells[cellKey(1, 2)] = { fill: 'green' } - - const result = sectorColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('infers opposite color from onlyOne sector when one adjacent cell color is known', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.cells[cellKey(1, 2)] = { fill: 'green' } - - const result = sectorColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'yellow', - }) - }) - - it('treats onlyZero and onlyTwo as notOne and infers same color', () => { - const onlyZeroPuzzle = createSlitherPuzzle(3, 3) - onlyZeroPuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_0 - onlyZeroPuzzle.cells[cellKey(1, 2)] = { fill: 'yellow' } - - const onlyZeroResult = sectorColorRule.apply(onlyZeroPuzzle) - - expect(onlyZeroResult).not.toBeNull() - expect(onlyZeroResult?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'yellow', - }) - - const onlyTwoPuzzle = createSlitherPuzzle(3, 3) - onlyTwoPuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_2 - onlyTwoPuzzle.cells[cellKey(1, 2)] = { fill: 'green' } - - const onlyTwoResult = sectorColorRule.apply(onlyTwoPuzzle) - - expect(onlyTwoResult).not.toBeNull() - expect(onlyTwoResult?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(2, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('treats out-of-bounds adjacent cell as yellow at boundary', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1 - - const result = sectorColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'cell', - cellKey: cellKey(0, 1), - fromFill: null, - toFill: 'green', - }) - }) - - it('infers onlyOne sectors from different diagonal-adjacent colors', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } - - const result = sectorColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(0, 0, 'se'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }) - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, 'nw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }) - }) - - it('infers notOne sectors from same diagonal-adjacent colors', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - - const result = sectorColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(0, 0, 'se'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }) - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, 'nw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }) - }) - - it('uses out-of-bounds yellow for color-to-sector propagation at the boundary', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - - const result = sectorColorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(0, 0, 'ne'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }) - expect(result?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(-1, 1, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }) - }) - - it('does not emit redundant or invalid color-to-sector updates for the target sectors', () => { - const compatiblePuzzle = createSlitherPuzzle(3, 3) - compatiblePuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - compatiblePuzzle.sectors[sectorKey(2, 2, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - compatiblePuzzle.cells[cellKey(1, 2)] = { fill: 'green' } - compatiblePuzzle.cells[cellKey(2, 1)] = { fill: 'yellow' } - - const compatibleResult = sectorColorRule.apply(compatiblePuzzle) - - expect(compatibleResult?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, 'se'), - fromMask: SECTOR_MASK_ONLY_1, - toMask: SECTOR_MASK_ONLY_1, - }) - expect(compatibleResult?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(2, 2, 'nw'), - fromMask: SECTOR_MASK_ONLY_1, - toMask: SECTOR_MASK_ONLY_1, - }) - - const invalidPuzzle = createSlitherPuzzle(3, 3) - invalidPuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 - invalidPuzzle.sectors[sectorKey(2, 2, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - invalidPuzzle.cells[cellKey(1, 2)] = { fill: 'green' } - invalidPuzzle.cells[cellKey(2, 1)] = { fill: 'yellow' } - - const invalidResult = sectorColorRule.apply(invalidPuzzle) - - expect(invalidResult?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, 'se'), - fromMask: SECTOR_MASK_NOT_1, - toMask: SECTOR_MASK_ONLY_1, - }) - expect(invalidResult?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(2, 2, 'nw'), - fromMask: SECTOR_MASK_NOT_1, - toMask: SECTOR_MASK_ONLY_1, - }) - }) - - it('does not apply when both adjacent cells are unknown and in bounds', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 - - const result = sectorColorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('skips conflicting color-to-sector inference without reporting an invalid mask', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.cells[cellKey(1, 0)] = { fill: 'green' } - - const result = sectorColorRule.apply(puzzle) - - expect(result?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(0, 0, 'se'), - fromMask: SECTOR_MASK_ONLY_1, - toMask: SECTOR_MASK_NOT_1, - }) - expect(result?.diffs).not.toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, 'nw'), - fromMask: SECTOR_MASK_ONLY_1, - toMask: SECTOR_MASK_NOT_1, - }) - }) -}) - -describe('slither prevent premature loop rule', () => { - const antiLoopRule = slitherRules.find((rule) => rule.id === 'prevent-premature-loop') - if (!antiLoopRule) { - throw new Error('Expected prevent-premature-loop rule') - } - - it('is ordered after vertex-degree, exact sector edge propagation, coloring, and clue-propagation', () => { - const vertexRuleIdx = slitherRules.findIndex((rule) => rule.id === 'vertex-degree') - const sectorEdgeRuleIdx = slitherRules.findIndex((rule) => rule.id === 'sector-constraint-edge-propagation') - const outsideRuleIdx = slitherRules.findIndex((rule) => rule.id === 'color-outside-seeding') - const colorRuleIdx = slitherRules.findIndex((rule) => rule.id === 'color-edge-propagation') - const clueRuleIdx = slitherRules.findIndex((rule) => rule.id === 'color-clue-propagation') - const sectorColorRuleIdx = slitherRules.findIndex((rule) => rule.id === 'color-sector-mask-propagation') - const orthogonalConsensusRuleIdx = slitherRules.findIndex( - (rule) => rule.id === 'color-orthogonal-consensus-propagation', - ) - const reachabilityRuleIdx = slitherRules.findIndex((rule) => rule.id === 'inside-reachability-coloring') - const outsideReachabilityRuleIdx = slitherRules.findIndex((rule) => rule.id === 'outside-reachability-coloring') - const cutColorRuleIdx = slitherRules.findIndex((rule) => rule.id === 'color-connectivity-cut-coloring') - const antiLoopRuleIdx = slitherRules.findIndex((rule) => rule.id === 'prevent-premature-loop') - expect(vertexRuleIdx).toBeGreaterThanOrEqual(0) - expect(sectorEdgeRuleIdx).toBe(vertexRuleIdx + 1) - expect(outsideRuleIdx).toBe(sectorEdgeRuleIdx + 1) - expect(colorRuleIdx).toBe(outsideRuleIdx + 1) - expect(clueRuleIdx).toBe(colorRuleIdx + 1) - expect(sectorColorRuleIdx).toBe(clueRuleIdx + 1) - expect(orthogonalConsensusRuleIdx).toBe(sectorColorRuleIdx + 1) - expect(reachabilityRuleIdx).toBe(orthogonalConsensusRuleIdx + 1) - expect(outsideReachabilityRuleIdx).toBe(reachabilityRuleIdx + 1) - expect(cutColorRuleIdx).toBe(outsideReachabilityRuleIdx + 1) - expect(antiLoopRuleIdx).toBe(cutColorRuleIdx + 1) - }) - - it('marks an unknown edge blank when it would close a loop', () => { - const puzzle = createSlitherPuzzle(2, 2) - const top = edgeKey([0, 0], [0, 1]) - const right = edgeKey([0, 1], [1, 1]) - const bottom = edgeKey([1, 0], [1, 1]) - const closing = edgeKey([0, 0], [1, 0]) - puzzle.edges[top].mark = 'line' - puzzle.edges[right].mark = 'line' - puzzle.edges[bottom].mark = 'line' - - const result = antiLoopRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(getEdgeDiffKeys(result)).toEqual([closing]) - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: closing, from: 'unknown', to: 'blank' }]) - }) - - it('marks all loop-closing unknown edges in one application', () => { - const puzzle = createSlitherPuzzle(3, 3) - - const topLeftTop = edgeKey([0, 0], [0, 1]) - const topLeftRight = edgeKey([0, 1], [1, 1]) - const topLeftBottom = edgeKey([1, 0], [1, 1]) - const topLeftClosing = edgeKey([0, 0], [1, 0]) - - const bottomRightTop = edgeKey([2, 2], [2, 3]) - const bottomRightRight = edgeKey([2, 3], [3, 3]) - const bottomRightBottom = edgeKey([3, 2], [3, 3]) - const bottomRightClosing = edgeKey([2, 2], [3, 2]) - - puzzle.edges[topLeftTop].mark = 'line' - puzzle.edges[topLeftRight].mark = 'line' - puzzle.edges[topLeftBottom].mark = 'line' - puzzle.edges[bottomRightTop].mark = 'line' - puzzle.edges[bottomRightRight].mark = 'line' - puzzle.edges[bottomRightBottom].mark = 'line' - - const result = antiLoopRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(getEdgeDiffKeys(result)).toEqual([topLeftClosing, bottomRightClosing]) - expect( - result?.diffs.every((d) => d.kind === 'edge' && d.from === 'unknown' && d.to === 'blank'), - ).toBe(true) - }) - - it('does not apply when unknown edges do not close a loop', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 1], [2, 1])].mark = 'line' - - const result = antiLoopRule.apply(puzzle) - - expect(result).toBeNull() - }) -}) - -describe('slither sector notOne clue-2 propagation rule', () => { - const propagationRule = slitherRules.find((rule) => rule.id === 'sector-not-one-clue-two-propagation') - if (!propagationRule) { - throw new Error('Expected sector-not-one-clue-two-propagation rule') - } - - it('marks target corner edges blank when clue=2, target sector is notOne, and opposite corner has a line', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - const [seTopOrBottom] = getCornerEdgeKeys(0, 0, 'se') - puzzle.edges[seTopOrBottom].mark = 'line' - - const result = propagationRule.apply(puzzle) - - expect(result).not.toBeNull() - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: nwTop, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }, - ]) - expect(result?.affectedCells).toEqual([cellKey(0, 0)]) - expect(result?.affectedSectors).toEqual([sectorKey(0, 0, 'nw'), sectorKey(0, 0, 'se')]) - }) - - it('does not apply when clue is not 2', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 3) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - const [seEdge] = getCornerEdgeKeys(0, 0, 'se') - puzzle.edges[seEdge].mark = 'line' - - const result = propagationRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not apply when opposite corner has no line', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - - const result = propagationRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('is idempotent when target corner edges are already decided', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - const [seEdge] = getCornerEdgeKeys(0, 0, 'se') - puzzle.edges[seEdge].mark = 'line' - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - puzzle.edges[nwTop].mark = 'blank' - puzzle.edges[nwLeft].mark = 'blank' - - const result = propagationRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('appears during stepwise solving for the provided 10x10 puzzle', () => { - let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/10/10/zsan23dzzq') - let triggered = false - - for (let stepNumber = 1; stepNumber <= 600; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - if ( - step.ruleId === 'sector-not-one-clue-two-propagation' || - step.ruleId === 'clue-vertex-candidate-combination-pruning' - ) { - triggered = true - break - } - current = nextPuzzle - } - - expect(triggered).toBe(true) - }) -}) - -describe('slither sector diagonal shared-vertex propagation rule', () => { - const diagonalSectorRule = slitherRules.find( - (rule) => rule.id === 'sector-diagonal-shared-vertex-propagation', - ) - if (!diagonalSectorRule) { - throw new Error('Expected sector-diagonal-shared-vertex-propagation rule') - } - - it('propagates onlyOne from A.ne to B.sw', () => { - const puzzle = createSlitherPuzzle(4, 4) - puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1 - - const result = diagonalSectorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { - kind: 'sector', - sectorKey: sectorKey(1, 3, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }, - ]) - expect(result?.affectedCells).toEqual([cellKey(2, 2), cellKey(1, 3)]) - expect(result?.affectedSectors).toEqual([sectorKey(2, 2, 'ne'), sectorKey(1, 3, 'sw')]) - }) - - it('propagates notOne from A.ne to B.sw', () => { - const puzzle = createSlitherPuzzle(4, 4) - puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_1 - - const result = diagonalSectorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { - kind: 'sector', - sectorKey: sectorKey(1, 3, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - ]) - }) - - it('propagates notZero from A.ne to B.sw as notTwo', () => { - const puzzle = createSlitherPuzzle(4, 4) - puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_0 - - const result = diagonalSectorRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { - kind: 'sector', - sectorKey: sectorKey(1, 3, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_2, - }, - ]) - }) - - it('does not apply when diagonal target cell is out of bounds', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - - const result = diagonalSectorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('is idempotent when target sector is already equally constrained', () => { - const puzzle = createSlitherPuzzle(4, 4) - puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_0 - puzzle.sectors[sectorKey(1, 3, 'sw')].constraintsMask = SECTOR_MASK_NOT_2 - - const result = diagonalSectorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('skips conflicts when intersection would become zero', () => { - const puzzle = createSlitherPuzzle(4, 4) - puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_0 - puzzle.sectors[sectorKey(1, 3, 'sw')].constraintsMask = SECTOR_MASK_ONLY_2 - - const result = diagonalSectorRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('appears during stepwise solving for the provided 8x8 puzzle', () => { - const rulesWithoutCutColoring = slitherRules.filter((rule) => rule.id !== 'color-connectivity-cut-coloring') - let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/8/8/gdg1dddbdid26d72ccicadc3cgc') - let triggered = false - - for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, rulesWithoutCutColoring, stepNumber) - if (!step) { - break - } - if (step.ruleId === 'sector-diagonal-shared-vertex-propagation') { - triggered = true - break - } - current = nextPuzzle - } - - expect(triggered).toBe(true) - }) - - it('appears or becomes unnecessary during stepwise solving for the provided 10x10 puzzle', () => { - let current = decodeSlitherFromPuzzlink( - 'https://puzz.link/p?slither/10/10/ga337ddkdh2adbgdi20dp23dibgbd0dhdkd511da', - ) - let triggered = false - - for (let stepNumber = 1; stepNumber <= 1200; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - if (step.ruleId === 'sector-diagonal-shared-vertex-propagation') { - triggered = true - break - } - current = nextPuzzle - } - - expect(triggered || diagonalSectorRule.apply(current) === null).toBe(true) - }) -}) - -describe('slither vertex candidate edge pruning rule', () => { - const vertexRule = slitherRules.find((rule) => rule.id === 'vertex-candidate-edge-pruning') - if (!vertexRule) { - throw new Error('Expected vertex-candidate-edge-pruning rule') - } - - it('prunes vertex candidates from known line and blank edges and forces the remaining continuation', () => { - const puzzle = createSlitherPuzzle(2, 2) - const [up, down, left, right] = getVertexIncidentEdges(1, 1, puzzle.rows, puzzle.cols) - puzzle.edges[up].mark = 'line' - puzzle.edges[down].mark = 'blank' - puzzle.edges[left].mark = 'blank' - - const result = vertexRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'vertex', - vertexKey: vertexKey(1, 1), - fromCandidates: puzzle.vertices[vertexKey(1, 1)].candidateEdgeSets, - toCandidates: [[right, up].sort()], - }) - expect(result?.diffs).toContainEqual({ kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }) - }) -}) - -describe('slither clue vertex-candidate combination pruning rule', () => { - const combinationRule = slitherRules.find((rule) => rule.id === 'clue-vertex-candidate-combination-pruning') - if (!combinationRule) { - throw new Error('Expected clue-vertex-candidate-combination-pruning rule') - } - - it('prunes clue-0 corners to onlyZero sector masks', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 0) - - const result = combinationRule.apply(puzzle) - - expect(result).not.toBeNull() - for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, corner), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_0, - }) - } - expect(result?.diffs.some((diff) => diff.kind === 'vertex')).toBe(true) - }) - - it('prunes clue-1 corners to notTwo sector masks', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 1) - - const result = combinationRule.apply(puzzle) - - expect(result).not.toBeNull() - for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, corner), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_2, - }) - } - }) - - it('prunes clue-2 boundary corners using the full four-corner candidate check', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 0, 0, 2) - - const result = combinationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'nw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'ne'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'se'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - ]), - ) - }) - - it('prunes clue-3 corners to notZero sector masks', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 1, 1, 3) - - const result = combinationRule.apply(puzzle) - - expect(result).not.toBeNull() - for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(1, 1, corner), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_0, - }) - } - }) - - it('removes a vertex candidate that is locally legal but unsupported by a neighboring clue', () => { - const puzzle = createSlitherPuzzle(3, 3) - setClue(puzzle, 0, 0, 0) - const [down, right] = getVertexIncidentEdges(0, 0, puzzle.rows, puzzle.cols) - - const result = combinationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'vertex', - vertexKey: vertexKey(0, 0), - fromCandidates: [[], [down, right].sort()], - toCandidates: [[]], - }) - }) - - it('appears during stepwise solving for the provided 5x5 line-case puzzle', () => { - let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/5/5/hdhdhcp') - let triggered = false - - for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - if (step.ruleId === 'clue-vertex-candidate-combination-pruning') { - triggered = true - break - } - current = nextPuzzle - } - - expect(triggered).toBe(true) - }) -}) - -describe('slither sector constraint edge propagation rule', () => { - const edgePropagationRule = slitherRules.find((rule) => rule.id === 'sector-constraint-edge-propagation') - if (!edgePropagationRule) { - throw new Error('Expected sector-constraint-edge-propagation rule') - } - - it('forces both corner edges to line when sector mask is onlyTwo', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_2 - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - - const result = edgePropagationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: nwTop, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }, - ]) - }) - - it('forces both corner edges to blank when sector mask is onlyZero', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_0 - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - - const result = edgePropagationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: nwTop, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }, - ]) - }) - - it('propagates the provided 10x10 top-edge onlyTwo sector to lines immediately after inference', () => { - let current = decodeSlitherFromPuzzlink( - 'https://puzz.link/p?slither/10/10/g88b227637bg2067a7bj8c6a223c1adh1cb1bi32di1dc33783', - ) - const targetSector = sectorKey(0, 1, 'ne') - const targetEdges = getCornerEdgeKeys(0, 1, 'ne') - let sawOnlyTwoInference = false - - for (let stepNumber = 1; stepNumber <= 80; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - - if (!sawOnlyTwoInference) { - const sectorDiff = step.diffs.find((d) => d.kind === 'sector' && d.sectorKey === targetSector) - if (sectorDiff?.kind === 'sector' && sectorDiff.toMask === SECTOR_MASK_ONLY_2) { - if (targetEdges.every((edge) => nextPuzzle.edges[edge]?.mark === 'line')) { - return - } - sawOnlyTwoInference = true - current = nextPuzzle - continue - } - } else { - expect(step.ruleId).toBe('sector-constraint-edge-propagation') - expect(step.diffs).toEqual( - expect.arrayContaining( - targetEdges.map((edge) => ({ - kind: 'edge' as const, - edgeKey: edge, - from: 'unknown' as const, - to: 'line' as const, - })), - ), - ) - return - } - - current = nextPuzzle - } - - throw new Error('Expected sector inference followed by immediate edge propagation for (R1, C2, NE).') - }) - - it('forces the last unknown corner edge to line when onlyOne with one blank', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - puzzle.edges[nwTop].mark = 'blank' - - const result = edgePropagationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }]) - }) - - it('forces the last unknown corner edge to line when notOne already has one line', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - puzzle.edges[nwTop].mark = 'line' - - const result = edgePropagationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }]) - }) - - it('forces the last unknown corner edge to blank when notOne already has one blank', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - puzzle.edges[nwTop].mark = 'blank' - - const result = edgePropagationRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }]) - }) - - it('does not emit a notOne propagation diff when both corner edges are already decided', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - puzzle.edges[nwTop].mark = 'line' - puzzle.edges[nwLeft].mark = 'blank' - - const result = edgePropagationRule.apply(puzzle) - - expect(result).toBeNull() - }) -}) - -describe('slither sector clue-1/3 onlyOne opposite edges rule', () => { - const clueOneThreeRule = slitherRules.find((rule) => rule.id === 'sector-clue-one-three-intra-cell-propagation') - if (!clueOneThreeRule) { - throw new Error('Expected sector-clue-one-three-intra-cell-propagation rule') - } - - it('forces the two edges not in the sector to blank when clue is 1 and sector is onlyOne', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 1) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - const cellEdges = getCellEdgeKeys(0, 0) - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - const opposite = cellEdges.filter((e) => e !== nwTop && e !== nwLeft) - - const result = clueOneThreeRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(opposite).toHaveLength(2) - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'edge', edgeKey: opposite[0], from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: opposite[1], from: 'unknown', to: 'blank' }, - ]), - ) - expect(result?.diffs).toHaveLength(2) - expect(result?.affectedCells).toEqual(['0,0']) - expect(result?.affectedSectors).toContain(sectorKey(0, 0, 'nw')) - }) - - it('forces the two edges not in the sector to line when clue is 3 and sector is onlyOne', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 3) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - const cellEdges = getCellEdgeKeys(0, 0) - const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') - const opposite = cellEdges.filter((e) => e !== nwTop && e !== nwLeft) - - const result = clueOneThreeRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { kind: 'edge', edgeKey: opposite[0], from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: opposite[1], from: 'unknown', to: 'line' }, - ]), - ) - expect(result?.diffs).toHaveLength(2) - }) -}) - -describe('slither vertex onlyOne non-sector balance rule', () => { - const vertexBalanceRule = slitherRules.find((rule) => rule.id === 'vertex-onlyone-non-sector-balance') - if (!vertexBalanceRule) { - throw new Error('Expected vertex-onlyone-non-sector-balance rule') - } - - it('forces the other non-sector edge to line when one non-sector edge is blank', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - const bottom = edgeKey([1, 1], [2, 1]) - const right = edgeKey([1, 1], [1, 2]) - puzzle.edges[bottom].mark = 'blank' - - const result = vertexBalanceRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }]) - expect(result?.affectedSectors).toContain(sectorKey(0, 0, 'se')) - }) - - it('forces the other non-sector edge to blank when one non-sector edge is line', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - const bottom = edgeKey([1, 1], [2, 1]) - const right = edgeKey([1, 1], [1, 2]) - puzzle.edges[right].mark = 'line' - - const result = vertexBalanceRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }]) - }) - - it('supports diagonal-sector narrative: onlyOne on one diagonal plus blank on opposite diagonal edge forces line', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 1, 'sw')].constraintsMask = SECTOR_MASK_ONLY_1 - - const oppositeDiagonalEdgeA = edgeKey([1, 0], [1, 1]) - const oppositeDiagonalEdgeB = edgeKey([1, 1], [2, 1]) - puzzle.edges[oppositeDiagonalEdgeA].mark = 'blank' - - const result = vertexBalanceRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: oppositeDiagonalEdgeB, from: 'unknown', to: 'line' }]) - expect(result?.affectedSectors).toContain(sectorKey(0, 1, 'sw')) - }) - - it('forces the single non-sector boundary edge to line when a boundary corner sector is onlyOne', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - - const result = vertexBalanceRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: edgeKey([0, 0], [0, 1]), from: 'unknown', to: 'line' }, - ]) - expect(result?.affectedSectors).toContain(sectorKey(0, 1, 'nw')) - }) - - it('is idempotent on boundary single non-sector case when edge is already decided', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - const forced = edgeKey([0, 0], [0, 1]) - puzzle.edges[forced].mark = 'line' - - const result = vertexBalanceRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('does not force boundary non-sector edge when sector is not onlyOne', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 - - const result = vertexBalanceRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('leaves the provided 5x5 cgcx boundary line to color-edge propagation during stepwise solving', () => { - let current = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/5/5/cgcx') - let colorEdgeTriggered = false - let sawBoundaryLine = false - - for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, slitherRules, stepNumber) - if (!step) { - break - } - if (step.ruleId === 'color-edge-propagation') { - for (const diff of step.diffs) { - if (diff.kind !== 'edge' || diff.to !== 'line') { - continue - } - const [a, b] = parseEdgeKey(diff.edgeKey) - const isBoundary = - (a[0] === 0 && b[0] === 0) || - (a[0] === current.rows && b[0] === current.rows) || - (a[1] === 0 && b[1] === 0) || - (a[1] === current.cols && b[1] === current.cols) - if (isBoundary) { - sawBoundaryLine = true - break - } - } - if (sawBoundaryLine) { - colorEdgeTriggered = true - break - } - } - current = nextPuzzle - } - - expect(colorEdgeTriggered).toBe(true) - expect(sawBoundaryLine).toBe(true) - }) -}) - -describe('slither apply sectors rule', () => { - const applySectorsRule = slitherRules.find((rule) => rule.id === 'sector-inference') - if (!applySectorsRule) { - throw new Error('Expected sector-inference rule') - } - - it('applies notZero sectors for clue 3 corners', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 3) - puzzle.sectors[sectorKey(0, 1, 'ne')].constraintsMask = SECTOR_MASK_NOT_1 - puzzle.sectors[sectorKey(1, 0, 'sw')].constraintsMask = SECTOR_MASK_NOT_1 - puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 - - const result = applySectorsRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.message).toContain('narrow the allowed corner line counts') - expect(result?.affectedCells).toEqual(['0,0']) - expect(result?.affectedSectors).toEqual([ - sectorKey(0, 0, 'nw'), - sectorKey(0, 0, 'ne'), - sectorKey(0, 0, 'sw'), - sectorKey(0, 0, 'se'), - ]) - expect(result?.diffs.every((d) => d.kind === 'sector')).toBe(true) - expect(result?.diffs).toEqual([ - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'nw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_2, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'ne'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_0, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_0, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'se'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_0, - }, - ]) - }) - - it('applies onlyOne mask when corner has one line and one blank edge', () => { - const puzzle = createSlitherPuzzle(2, 3) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' - - const result = applySectorsRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.affectedSectors).toContain(sectorKey(0, 0, 'ne')) - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(0, 0, 'ne'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }) - }) - - it('infers onlyOne for (0,3) nw on a boundary vertex with three incident edges (puzz.link 4×4)', () => { - const puzzle = decodeSlitherFromPuzzlink('https://puzz.link/p?slither/4/4/183aibi') - expect(puzzle.cells[cellKey(0, 3)]?.clue).toEqual({ kind: 'number', value: 3 }) - - // Vertex (0,3) has only three incident edges; the edge west of that vertex is outside the - // (0,3)-nw sector. One line there forces the sector to contribute exactly one line (step 1.2). - const westOfVertex = edgeKey([0, 2], [0, 3]) - puzzle.edges[westOfVertex].mark = 'line' - - const result = applySectorsRule.apply(puzzle) - - expect(result).not.toBeNull() - const nw03 = sectorKey(0, 3, 'nw') - expect(result?.affectedSectors).toContain(nw03) - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: nw03, - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_ONLY_1, - }) - }) - - it('tightens board-corner sectors to notOne from natural boundary geometry', () => { - const puzzle = createSlitherPuzzle(2, 2) - - const result = applySectorsRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual( - expect.arrayContaining([ - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'nw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - { - kind: 'sector', - sectorKey: sectorKey(0, 1, 'ne'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - { - kind: 'sector', - sectorKey: sectorKey(1, 0, 'sw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - { - kind: 'sector', - sectorKey: sectorKey(1, 1, 'se'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }, - ]), - ) - }) - - it('does not relax already-strong corner sector masks', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_0 - puzzle.sectors[sectorKey(0, 1, 'ne')].constraintsMask = SECTOR_MASK_ONLY_2 - puzzle.sectors[sectorKey(1, 0, 'sw')].constraintsMask = SECTOR_MASK_ONLY_0 - puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_2 - - const result = applySectorsRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('tightens edge non-corner sector to notOne when its only non-sector edge is blank', () => { - const puzzle = createSlitherPuzzle(3, 3) - const nonSectorEdge = edgeKey([0, 0], [0, 1]) - puzzle.edges[nonSectorEdge].mark = 'blank' - - const result = applySectorsRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toContainEqual({ - kind: 'sector', - sectorKey: sectorKey(0, 1, 'nw'), - fromMask: SECTOR_MASK_ALL, - toMask: SECTOR_MASK_NOT_1, - }) - }) - - it('does not relax already-strong edge non-corner sector masks under boundary blank evidence', () => { - const puzzle = createSlitherPuzzle(3, 3) - const nonSectorEdge = edgeKey([0, 0], [0, 1]) - puzzle.edges[nonSectorEdge].mark = 'blank' - const targetSector = sectorKey(0, 1, 'nw') - puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_ONLY_0 - - const result = applySectorsRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs.some((d) => d.kind === 'sector' && d.sectorKey === targetSector)).toBe(false) - }) - - it('returns null when sectors are already up to date', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 3) - const first = applySectorsRule.apply(puzzle) - if (!first) { - throw new Error('Expected first apply-sectors result') - } - for (const diff of first.diffs) { - if (diff.kind === 'sector') { - puzzle.sectors[diff.sectorKey].constraintsMask = diff.toMask - } - } - - const second = applySectorsRule.apply(puzzle) - - expect(second).toBeNull() - }) -}) - -describe('slither sector parity inference rule', () => { - const sectorParityRule = slitherRules.find((rule) => rule.id === 'sector-parity-inference') - if (!sectorParityRule) { - throw new Error('Expected sector-parity-inference rule') - } - - it('places sector parity inference after color assumption and before strong inference', () => { - const colorAssumptionIdx = slitherRules.findIndex((rule) => rule.id === 'color-assumption-inference') - const sectorParityIdx = slitherRules.findIndex((rule) => rule.id === 'sector-parity-inference') - const strongIdx = slitherRules.findIndex((rule) => rule.id === 'strong-inference') - - expect(colorAssumptionIdx).toBeGreaterThanOrEqual(0) - expect(sectorParityIdx).toBe(colorAssumptionIdx + 1) - expect(strongIdx).toBe(sectorParityIdx + 1) - }) - - it('forces both notOne sector edges blank when the both-line branch creates a closed subloop', () => { - const directSectorParityRule = createSectorParityInferenceRule(() => []) - const puzzle = createSlitherPuzzle(2, 2) - const targetSector = sectorKey(0, 0, 'se') - puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1 - const [bottom, right] = getCornerEdgeKeys(0, 0, 'se') - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line' - - const result = directSectorParityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, - ]) - expect(result?.affectedSectors).toEqual([targetSector]) - expect(result?.message).toContain('cannot have exactly one line') - expect(result?.message).toContain('contradicts the puzzle') - }) - - it('forces both notOne sector edges line when the both-blank branch violates a clue', () => { - const directSectorParityRule = createSectorParityInferenceRule(() => []) - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 2) - const targetSector = sectorKey(0, 0, 'se') - puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1 - const [bottom, right] = getCornerEdgeKeys(0, 0, 'se') - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'blank' - - const result = directSectorParityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }, - ]) - expect(result?.affectedSectors).toEqual([targetSector]) - expect(result?.message).toContain('contradicts the puzzle') - }) - - it('forces a sector parity branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { - const puzzle = createSlitherPuzzle(1, 1) - setClue(puzzle, 0, 0, 2) - const targetSector = sectorKey(0, 0, 'nw') - puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1 - const [top, left] = getCornerEdgeKeys(0, 0, 'nw') - const bottom = edgeKey([1, 0], [1, 1]) - const right = edgeKey([0, 1], [1, 1]) - const stallRule: Rule = { - id: 'sector-parity-stall-test', - name: 'Sector Parity Stall Test', - apply: (trial) => { - const topMark = trial.edges[top]?.mark ?? 'unknown' - const leftMark = trial.edges[left]?.mark ?? 'unknown' - if (topMark === 'blank' && leftMark === 'blank') { - return { - message: 'make blank branch contradict the clue', - diffs: [ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, - ], - affectedCells: [cellKey(0, 0)], - } - } - if (topMark !== 'line' || leftMark !== 'line') { - return null - } - return { - message: 'keep line branch unresolved', - diffs: [ - { - kind: 'sector', - sectorKey: targetSector, - fromMask: trial.sectors[targetSector].constraintsMask, - toMask: trial.sectors[targetSector].constraintsMask, - }, - ], - affectedCells: [cellKey(0, 0)], - affectedSectors: [targetSector], - } - }, - } - const probingSectorParityRule = createSectorParityInferenceRule(() => [stallRule], { - maxMs: Number.POSITIVE_INFINITY, - maxTrialSteps: 24, - }) - - const result = probingSectorParityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, - ]) - expect(result?.message).toContain('probe budget 24') - expect(result?.message).toContain('line branch unresolved after 24 steps') - expect(result?.message).toContain('blank branch contradicted after 1 step') - }) - - it('checks later sector parity candidates at the first probe budget', () => { - const puzzle = createSlitherPuzzle(2, 1) - const firstSector = sectorKey(0, 0, 'nw') - const secondSector = sectorKey(1, 0, 'nw') - puzzle.sectors[firstSector].constraintsMask = SECTOR_MASK_NOT_1 - puzzle.sectors[secondSector].constraintsMask = SECTOR_MASK_NOT_1 - setClue(puzzle, 1, 0, 2) - const bottom = edgeKey([2, 0], [2, 1]) - const right = edgeKey([1, 1], [2, 1]) - const [firstA, firstB] = getCornerEdgeKeys(0, 0, 'nw') - const [secondA, secondB] = getCornerEdgeKeys(1, 0, 'nw') - const stallRule: Rule = { - id: 'sector-parity-first-candidate-stall-test', - name: 'Sector Parity First Candidate Stall Test', - apply: (trial) => { - if ((trial.edges[secondA]?.mark ?? 'unknown') === 'blank' && (trial.edges[secondB]?.mark ?? 'unknown') === 'blank') { - return { - message: 'make second blank branch contradict the clue', - diffs: [ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, - ], - affectedCells: [cellKey(1, 0)], - } - } - if ((trial.edges[firstA]?.mark ?? 'unknown') === 'unknown') { - return null - } - if ((trial.edges[firstB]?.mark ?? 'unknown') === 'unknown') { - return null - } - return { - message: 'keep first candidate unresolved', - diffs: [ - { - kind: 'sector', - sectorKey: firstSector, - fromMask: trial.sectors[firstSector].constraintsMask, - toMask: trial.sectors[firstSector].constraintsMask, - }, - ], - affectedCells: [cellKey(0, 0)], - affectedSectors: [firstSector], - } - }, - } - const probingSectorParityRule = createSectorParityInferenceRule(() => [stallRule], { - maxMs: Number.POSITIVE_INFINITY, - maxTrialSteps: 24, - }) - - const result = probingSectorParityRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: secondA, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: secondB, from: 'unknown', to: 'line' }, - ]) - expect(result?.affectedSectors).toEqual([secondSector]) - expect(result?.message).toContain('probe budget 24') - }) - - it('returns null when both notOne parity branches remain feasible', () => { - const directSectorParityRule = createSectorParityInferenceRule(() => []) - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_NOT_1 - - const result = directSectorParityRule.apply(puzzle) - - expect(result).toBeNull() - }) -}) - -describe('slither trial diagnostics', () => { - it('reports vertex-degree contradiction locations', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 1], [1, 2])].mark = 'line' - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('vertex-degree') - expect(reason?.message).toContain('V(1, 1)') - }) - - it('reports cell-clue contradiction locations', () => { - const puzzle = createSlitherPuzzle(1, 1) - setClue(puzzle, 0, 0, 0) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('cell-clue') - expect(reason?.message).toContain('(R1, C1)') - }) - - it('reports sector-mask contradiction locations', () => { - const puzzle = createSlitherPuzzle(1, 1) - const targetSector = sectorKey(0, 0, 'nw') - puzzle.sectors[targetSector].constraintsMask = 0 - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('sector-mask') - expect(reason?.message).toContain('(R1, C1, NW)') - }) - - it('reports vertex-candidates contradiction locations', () => { - const puzzle = createSlitherPuzzle(1, 1) - puzzle.vertices[vertexKey(0, 0)].candidateEdgeSets = [] - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('vertex-candidates') - expect(reason?.message).toContain('V(0, 0)') - }) - - it('reports color-edge contradiction locations', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - const shared = edgeKey([0, 1], [1, 1]) - puzzle.edges[shared].mark = 'line' - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('color-edge') - expect(reason?.message).toContain('edge V(0, 1)-V(1, 1)') - }) - - it('reports line-loop contradiction shape', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line' - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('line-loop') - expect(reason?.message).toContain('closed loop') - }) - - it('reports disconnected-green contradiction locations', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } - puzzle.cells[cellKey(0, 2)] = { fill: 'green' } - - const reason = findHardContradictionReason(puzzle) - - expect(reason?.kind).toBe('disconnected-green') - expect(reason?.message).toContain('(R1, C3)') - }) - - it('reports zero trial steps for immediate contradictions', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 1)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - - const result = runTrialUntilFixpoint(puzzle, [], 10, Number.POSITIVE_INFINITY) - - expect(result.contradiction).toBe(true) - expect(result.stepsRun).toBe(0) - expect(result.contradictionReason?.kind).toBe('color-edge') - }) - - it('keeps exhausted trial behavior while reporting steps run', () => { - const puzzle = createSlitherPuzzle(1, 2) - const target = edgeKey([0, 0], [0, 1]) - const oneStepRule: Rule = { - id: 'trial-diagnostic-one-step', - name: 'Trial Diagnostic One Step', - apply: (trial) => { - if ((trial.edges[target]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'test one trial step', - diffs: [{ kind: 'edge', edgeKey: target, from: 'unknown', to: 'blank' }], - affectedCells: [], - } - }, - } - - const result = runTrialUntilFixpoint(puzzle, [oneStepRule], 1, Number.POSITIVE_INFINITY) - - expect(result.contradiction).toBe(false) - expect(result.exhausted).toBe(true) - expect(result.stepsRun).toBe(1) - }) -}) - -describe('slither strong inference rule', () => { - const colorAssumptionRule = slitherRules.find((rule) => rule.id === 'color-assumption-inference') - if (!colorAssumptionRule) { - throw new Error('Expected color-assumption-inference rule') - } - const unboundedColorAssumptionRule = createColorAssumptionInferenceRule( - () => - slitherRules.filter( - (rule) => - rule.id !== 'color-assumption-inference' && - rule.id !== 'sector-parity-inference' && - rule.id !== 'strong-inference', - ), - { maxMs: Number.POSITIVE_INFINITY }, - ) - const strongRule = slitherRules.find((rule) => rule.id === 'strong-inference') - if (!strongRule) { - throw new Error('Expected strong-inference rule') - } - const unboundedStrongRule = createStrongInferenceRule( - () => - slitherRules.filter( - (rule) => - rule.id !== 'color-assumption-inference' && - rule.id !== 'sector-parity-inference' && - rule.id !== 'strong-inference', - ), - { maxMs: Number.POSITIVE_INFINITY }, - ) - - it('places color assumption inference before strong inference', () => { - const colorAssumptionIdx = slitherRules.findIndex((rule) => rule.id === 'color-assumption-inference') - const sectorParityIdx = slitherRules.findIndex((rule) => rule.id === 'sector-parity-inference') - const strongIdx = slitherRules.findIndex((rule) => rule.id === 'strong-inference') - expect(colorAssumptionIdx).toBeGreaterThanOrEqual(0) - expect(sectorParityIdx).toBe(colorAssumptionIdx + 1) - expect(strongIdx).toBe(sectorParityIdx + 1) - }) - - it('is placed at the end of slitherRules', () => { - expect(slitherRules[slitherRules.length - 1]?.id).toBe('strong-inference') - }) - - it('uses direct color-edge contradiction to force the opposite color', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - const shared = edgeKey([0, 1], [1, 1]) - puzzle.edges[shared].mark = 'line' - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }]) - expect(result?.message).toContain('contradiction') - expect(result?.message).toContain('color-edge contradiction') - expect(result?.message).toContain('edge V(0, 1)-V(1, 1)') - expect(result?.message).toContain('0 trial steps') - expect(result?.message).toContain('Searched 1 candidate') - expect(result?.message).toContain('is green') - }) - - it('compresses same-color candidate components before searching', () => { - const puzzle = createSlitherPuzzle(1, 4) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 3)] = { fill: 'yellow' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }]) - expect(result?.message).toContain('from 2 candidate cells') - expect(result?.message).toContain('compressed to 1 component') - }) - - it('compresses opposite-color candidate components connected by a line edge', () => { - const puzzle = createSlitherPuzzle(1, 4) - puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } - puzzle.cells[cellKey(0, 3)] = { fill: 'yellow' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line' - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }]) - expect(result?.message).toContain('from 2 candidate cells') - expect(result?.message).toContain('compressed to 1 component') - }) - - it('keeps the highest-scored representative within a compressed component', () => { - const puzzle = createSlitherPuzzle(1, 4) - puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } - puzzle.cells[cellKey(0, 3)] = { fill: 'yellow' } - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' - puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line' - puzzle.sectors[sectorKey(0, 2, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }]) - expect(result?.message).toContain('from 2 candidate cells') - expect(result?.message).toContain('compressed to 1 component') - }) - - it('uses boundary color contradiction to force the opposite color', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line' - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }]) - expect(result?.message).toContain('is yellow') - }) - - it('uses deterministic downstream propagation to find a contradiction', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - const shared = edgeKey([0, 1], [1, 1]) - const downstreamRule: Rule = { - id: 'downstream-color-test', - name: 'Downstream Color Test', - apply: (trial) => { - if (trial.cells[cellKey(0, 1)]?.fill !== 'green') { - return null - } - if ((trial.edges[shared]?.mark ?? 'unknown') !== 'unknown') { - return null - } - return { - message: 'test downstream edge consequence', - diffs: [{ kind: 'edge', edgeKey: shared, from: 'unknown', to: 'line' }], - affectedCells: [cellKey(0, 1)], - } - }, - } - const downstreamColorAssumptionRule = createColorAssumptionInferenceRule(() => [downstreamRule]) - - const result = downstreamColorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'yellow' }]) - expect(result?.message).toContain('is green') - expect(result?.message).toContain('after 1 trial step') - expect(result?.message).toContain('green branch: 1 step') - }) - - it('infers from a quick contradiction even when the opposite branch only reaches the probe budget', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' - const stallRule: Rule = { - id: 'stall-test', - name: 'Stall Test', - apply: (trial) => { - if (trial.cells[cellKey(0, 1)]?.fill !== 'green') { - return null - } - return { - message: 'keep this branch unresolved', - diffs: [ - { - kind: 'sector', - sectorKey: sectorKey(2, 2, 'se'), - fromMask: trial.sectors[sectorKey(2, 2, 'se')].constraintsMask, - toMask: trial.sectors[sectorKey(2, 2, 'se')].constraintsMask, - }, - ], - affectedCells: [], - affectedSectors: [sectorKey(2, 2, 'se')], - } - }, - } - const probingRule = createColorAssumptionInferenceRule(() => [stallRule], { - maxMs: Number.POSITIVE_INFINITY, - maxTrialSteps: 24, - }) - - const result = probingRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }]) - expect(result?.message).toContain('probe budget 24') - expect(result?.message).toContain('green branch: unresolved after 24 steps') - expect(result?.message).toContain('yellow branch: 0 steps') - }) - - it('moves to later components when an earlier component is unresolved at the current probe budget', () => { - const puzzle = createSlitherPuzzle(3, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.sectors[sectorKey(0, 1, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.sectors[sectorKey(0, 1, 'sw')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.sectors[sectorKey(0, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' - const stallRule: Rule = { - id: 'stall-test', - name: 'Stall Test', - apply: () => ({ - message: 'keep this branch unresolved', - diffs: [ - { - kind: 'sector', - sectorKey: sectorKey(2, 2, 'se'), - fromMask: puzzle.sectors[sectorKey(2, 2, 'se')].constraintsMask, - toMask: puzzle.sectors[sectorKey(2, 2, 'se')].constraintsMask, - }, - ], - affectedCells: [], - affectedSectors: [sectorKey(2, 2, 'se')], - }), - } - const probingRule = createColorAssumptionInferenceRule(() => [stallRule], { - maxMs: Number.POSITIVE_INFINITY, - maxTrialSteps: 24, - }) - - const result = probingRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(1, 0), fromFill: null, toFill: 'yellow' }]) - expect(result?.message).toContain('Searched 2 candidate components') - expect(result?.message).toContain('probe budget 24') - }) - - it('treats unreachable fixed green regions as a contradiction', () => { - const puzzle = createSlitherPuzzle(1, 3) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - puzzle.cells[cellKey(0, 2)] = { fill: 'green' } - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }]) - expect(result?.message).toContain('is yellow') - expect(result?.message).toContain('disconnected-green contradiction') - expect(result?.message).toContain('yellow branch: 0 steps') - }) - - it('returns null when both color branches remain feasible', () => { - const puzzle = createSlitherPuzzle(1, 2) - puzzle.cells[cellKey(0, 0)] = { fill: 'green' } - - const result = colorAssumptionRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('uses contradiction on onlyOne sector branches to force opposite assignment', () => { - const puzzle = createSlitherPuzzle(2, 2) - puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 - puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' - puzzle.edges[edgeKey([1, 0], [2, 0])].mark = 'blank' - - const result = strongRule.apply(puzzle) - - expect(result).not.toBeNull() - const bottom = edgeKey([1, 0], [1, 1]) - const right = edgeKey([0, 1], [1, 1]) - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, - ]) - expect(result?.affectedSectors).toEqual([sectorKey(0, 0, 'se')]) - expect(result?.message).toContain('must have exactly one line') - expect(result?.message).toContain('contradicts the puzzle') - }) - - it('returns null when both onlyOne branches remain feasible', () => { - const puzzle = createSlitherPuzzle(1, 1) - puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 - - const result = strongRule.apply(puzzle) - - expect(result).toBeNull() - }) - - it('supports vertex-two-choice contradiction on a boundary vertex', () => { - const directStrongRule = createStrongInferenceRule(() => []) - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 1) - - const up = edgeKey([0, 0], [1, 0]) - const down = edgeKey([1, 0], [2, 0]) - const right = edgeKey([1, 0], [1, 1]) - puzzle.edges[right].mark = 'line' - - const result = directStrongRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: up, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: down, from: 'unknown', to: 'line' }, - ]) - expect(result?.message).toContain('has two possible continuations') - expect(result?.message).toContain('contradicts the puzzle') - }) - - it('forces an edge branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { - const puzzle = createSlitherPuzzle(1, 1) - setClue(puzzle, 0, 0, 1) - const top = edgeKey([0, 0], [0, 1]) - const bottom = edgeKey([1, 0], [1, 1]) - const left = edgeKey([0, 0], [1, 0]) - const right = edgeKey([0, 1], [1, 1]) - const stallRule: Rule = { - id: 'strong-edge-stall-test', - name: 'Strong Edge Stall Test', - apply: (trial) => { - const topMark = trial.edges[top]?.mark ?? 'unknown' - if (topMark === 'blank') { - return { - message: 'make blank branch contradict the clue', - diffs: [ - { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, - ], - affectedCells: [cellKey(0, 0)], - } - } - if (topMark !== 'line') { - return null - } - return { - message: 'keep line branch unresolved', - diffs: [ - { - kind: 'sector', - sectorKey: sectorKey(0, 0, 'nw'), - fromMask: trial.sectors[sectorKey(0, 0, 'nw')].constraintsMask, - toMask: trial.sectors[sectorKey(0, 0, 'nw')].constraintsMask, - }, - ], - affectedCells: [cellKey(0, 0)], - affectedSectors: [sectorKey(0, 0, 'nw')], - } - }, - } - const probingStrongRule = createStrongInferenceRule(() => [stallRule], { - maxMs: Number.POSITIVE_INFINITY, - maxTrialSteps: 24, - }) - - const result = probingStrongRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }]) - expect(result?.message).toContain('probe budget 24') - expect(result?.message).toContain('A branch unresolved after 24 steps') - expect(result?.message).toContain('B branch contradicted after 1 step') - }) - - it('forces a binary strong branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { - const puzzle = createSlitherPuzzle(2, 2) - setClue(puzzle, 0, 0, 1) - - const up = edgeKey([0, 0], [1, 0]) - const down = edgeKey([1, 0], [2, 0]) - const right = edgeKey([1, 0], [1, 1]) - puzzle.edges[right].mark = 'line' - const stallRule: Rule = { - id: 'strong-binary-stall-test', - name: 'Strong Binary Stall Test', - apply: (trial) => { - if ((trial.edges[up]?.mark ?? 'unknown') !== 'blank') { - return null - } - if ((trial.edges[down]?.mark ?? 'unknown') !== 'line') { - return null - } - return { - message: 'keep surviving binary branch unresolved', - diffs: [ - { - kind: 'sector', - sectorKey: sectorKey(1, 0, 'nw'), - fromMask: trial.sectors[sectorKey(1, 0, 'nw')].constraintsMask, - toMask: trial.sectors[sectorKey(1, 0, 'nw')].constraintsMask, - }, - ], - affectedCells: [cellKey(1, 0)], - affectedSectors: [sectorKey(1, 0, 'nw')], - } - }, - } - const probingStrongRule = createStrongInferenceRule(() => [stallRule], { - maxMs: Number.POSITIVE_INFINITY, - maxTrialSteps: 24, - }) - - const result = probingStrongRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([ - { kind: 'edge', edgeKey: up, from: 'unknown', to: 'blank' }, - { kind: 'edge', edgeKey: down, from: 'unknown', to: 'line' }, - ]) - expect(result?.message).toContain('has two possible continuations') - expect(result?.message).toContain('probe budget 24') - expect(result?.message).toContain('A branch contradicted after 0 steps') - expect(result?.message).toContain('B branch unresolved after 24 steps') - }) - - it('extracts shared consequences when both feasible branches agree downstream', () => { - const puzzle = createSlitherPuzzle(2, 2) - const sector = sectorKey(0, 0, 'se') - puzzle.sectors[sector].constraintsMask = SECTOR_MASK_ONLY_1 - - const [bottom, right] = getCornerEdgeKeys(0, 0, 'se') - const top = edgeKey([0, 0], [0, 1]) - const sharedConsequenceRule: Rule = { - id: 'shared-consequence-test', - name: 'Shared Consequence Test', - apply: (trial) => { - if ((trial.edges[top]?.mark ?? 'unknown') !== 'unknown') { - return null - } - const bottomMark = trial.edges[bottom]?.mark ?? 'unknown' - const rightMark = trial.edges[right]?.mark ?? 'unknown' - if (bottomMark !== 'line' && rightMark !== 'line') { - return null - } - return { - message: 'test shared consequence', - diffs: [{ kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }], - affectedCells: [cellKey(0, 0)], - } - }, - } - const sharedStrongRule = createStrongInferenceRule(() => [sharedConsequenceRule]) - - const result = sharedStrongRule.apply(puzzle) - - expect(result).not.toBeNull() - expect(result?.diffs).toEqual([{ kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }]) - expect(result?.message).toContain('must have exactly one line') - expect(result?.message).toContain('same consequence') - expect(result?.message).toContain('probe budget') - }) - - it('can run on the provided 10x10 puzzle after deterministic stabilization', () => { - const rulesWithoutStrong = slitherRules.filter((rule) => rule.id !== 'strong-inference') - let current = decodeSlitherFromPuzzlink( - 'https://puzz.link/p?slither/10/10/c3ch08c0d22aodh1bgdbjbag3dhdo12c3a52ah3b0', - ) - - for (let stepNumber = 1; stepNumber <= 400; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, rulesWithoutStrong, stepNumber) - if (!step) { - break - } - current = nextPuzzle - } - - expect(() => unboundedStrongRule.apply(current)).not.toThrow() - const result = unboundedStrongRule.apply(current) - expect(result === null || result.diffs.length > 0).toBe(true) - }) - - it('lets deterministic cut coloring advance the provided 18x10 stuck puzzle', () => { - const rulesBeforeColorAssumption = slitherRules.filter( - (rule) => - rule.id !== 'color-assumption-inference' && - rule.id !== 'sector-parity-inference' && - rule.id !== 'strong-inference', - ) - let current = decodeSlitherFromPuzzlink( - 'https://puzz.link/p?slither/18/10/l12cg261b353didb1bbg112dgb2bbci161b3dgbhapchcg3c161dicb2bbg111cga2bbbi271c161bg31cj', - ) - - for (let stepNumber = 1; stepNumber <= 2000; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, rulesBeforeColorAssumption, stepNumber) - if (!step) { - break - } - current = nextPuzzle - } - - const result = unboundedColorAssumptionRule.apply(current) - expect(result).toBeNull() - expect(current.cells[cellKey(2, 12)]?.fill).toBe('green') - expect(current.cells[cellKey(7, 0)]?.fill).toBe('green') - - const targetBranch = clonePuzzle(current) - targetBranch.cells[cellKey(7, 0)] = { - ...(targetBranch.cells[cellKey(7, 0)] ?? {}), - fill: 'yellow', - } - const targetResult = runTrialUntilFixpoint( - targetBranch, - rulesBeforeColorAssumption, - 120, - Number.POSITIVE_INFINITY, - ) - expect(targetResult.contradiction).toBe(true) - }) - - it('keeps the provided 6x100 target edge covered after deterministic stabilization', () => { - const rulesWithoutStrong = slitherRules.filter((rule) => rule.id !== 'strong-inference') - const target = edgeKey([23, 0], [24, 0]) - let current = decodeSlitherFromPuzzlink( - 'https://puzz.link/p?slither/6/100/h1dgdabdg3bgdddbg2cgcddag0bgdcbag0bgdbcdg1cgbdddg1bgbdddg2dgaadbg1cgaddbg0bgdbacg1bgadccg3cgaacdg2cgbbadg3agbbbag3cgdcddg2bgcddag2bgaabdg2bgdbdag3bgcdbcg2cgdddbg2cgdddag2bgddcag2bgcdaag3bgdddcg2cgcaddg2bgabddg1bgdadcg3bgbdcdg1bgddddg1dgdbbdg3agbbdag1dgbdddg2agadddg1d', - ) - - for (let stepNumber = 1; stepNumber <= 2000; stepNumber += 1) { - const { nextPuzzle, step } = runNextRule(current, rulesWithoutStrong, stepNumber) - if (!step) { - break - } - current = nextPuzzle - } - - expect(current.edges[target]?.mark ?? 'unknown').toBe('blank') - }) -}) diff --git a/src/domain/rules/slither/rules/colorAssumptionInference.ts b/src/domain/rules/slither/rules/colorAssumptionInference.ts index 61f9492..5f6b6c8 100644 --- a/src/domain/rules/slither/rules/colorAssumptionInference.ts +++ b/src/domain/rules/slither/rules/colorAssumptionInference.ts @@ -1,6 +1,6 @@ import { clonePuzzle } from '../../../ir/normalize' import { cellKey, getCellEdgeKeys, sectorKey } from '../../../ir/keys' -import type { Rule, RuleApplication } from '../../types' +import type { InferenceDetails, Rule, RuleApplication } from '../../types' import { SECTOR_MASK_ALL, SECTOR_MASK_ONLY_1, @@ -16,7 +16,7 @@ import { oppositeSlitherCellColor, type SlitherCellColor, } from './shared' -import { runTrialUntilFixpoint, type TrialResult } from './trial' +import { buildSlitherInferenceBranch, runTrialUntilFixpoint, type TrialResult } from './trial' const COLOR_ASSUMPTION_MAX_CANDIDATES = 200 const COLOR_ASSUMPTION_MAX_TRIAL_STEPS = 50 @@ -230,6 +230,33 @@ const immediateContradictionResult = (puzzle: PuzzleIR): TrialResult => ({ kind: 'color-edge', message: 'setup contradiction: the assumed color is already incompatible with the current cell state', }, + traceSteps: [], +}) + +const buildInferenceDetails = ( + puzzle: PuzzleIR, + candidate: ColorAssumptionCandidate, + greenResult: TrialResult, + yellowResult: TrialResult, +): InferenceDetails => ({ + kind: 'slither-color-assumption', + conclusion: 'opposite-branch', + basePuzzle: clonePuzzle(puzzle), + defaultBranchId: greenResult.contradiction ? 'green' : 'yellow', + branches: [ + buildSlitherInferenceBranch( + 'green', + 'Green assumption', + getCellAssumptionDiff(puzzle, candidate, 'green'), + greenResult, + ), + buildSlitherInferenceBranch( + 'yellow', + 'Yellow assumption', + getCellAssumptionDiff(puzzle, candidate, 'yellow'), + yellowResult, + ), + ], }) const formatElapsedMs = (elapsedMs: number): string => `${Math.max(0, Math.round(elapsedMs))} ms` @@ -315,6 +342,7 @@ export const createColorAssumptionInferenceRule = ( message: `Assume ${describeCandidate(candidate)} is ${failingColor}; after ${formatTrialStepCount(failingResult.stepsRun)} / ${formatElapsedMs(failingResult.elapsedMs)}, deterministic propagation reaches ${describeContradiction(failingResult)}, so ${describeCandidate(candidate)} must be ${inferredColor}. Searched ${componentsSearched} candidate ${componentsSearched === 1 ? 'component' : 'components'} from ${rawCandidateCount} candidate ${rawCandidateCount === 1 ? 'cell' : 'cells'} at probe budget ${budget}; compressed to ${componentCount} ${componentCount === 1 ? 'component' : 'components'}; ${describeProbeBranch('green', greenResult)}; ${describeProbeBranch('yellow', yellowResult)}.`, diffs, affectedCells: [candidate.cellKey], + inferenceDetails: buildInferenceDetails(puzzle, candidate, greenResult, yellowResult), } } } diff --git a/src/domain/rules/slither/rules/sectorParityInference.ts b/src/domain/rules/slither/rules/sectorParityInference.ts index 07c5269..803a28b 100644 --- a/src/domain/rules/slither/rules/sectorParityInference.ts +++ b/src/domain/rules/slither/rules/sectorParityInference.ts @@ -1,12 +1,12 @@ import { clonePuzzle } from '../../../ir/normalize' import { cellKey, getCornerEdgeKeys, parseSectorKey } from '../../../ir/keys' -import type { Rule, RuleApplication } from '../../types' +import type { InferenceDetails, Rule, RuleApplication } from '../../types' import { SECTOR_MASK_NOT_1, type EdgeMark, type PuzzleIR, } from '../../../ir/types' -import { applyEdgeAssumption, runTrialUntilFixpoint, type TrialResult } from './trial' +import { applyEdgeAssumption, buildSlitherInferenceBranch, runTrialUntilFixpoint, type TrialResult } from './trial' import { formatEdgeLabel, formatSectorKeyLabel } from './shared' const SECTOR_PARITY_MAX_CANDIDATES = 200 @@ -43,6 +43,30 @@ const immediateContradictionResult = (puzzle: PuzzleIR): TrialResult => ({ kind: 'sector-mask', message: 'setup contradiction: this parity branch is already incompatible with the current edge state', }, + traceSteps: [], +}) + +const buildInferenceDetails = ( + puzzle: PuzzleIR, + conclusion: InferenceDetails['conclusion'], + lineBranch: SectorParityBranch, + lineResult: TrialResult, + blankBranch: SectorParityBranch, + blankResult: TrialResult, +): InferenceDetails => ({ + kind: 'slither-sector-parity', + conclusion, + basePuzzle: clonePuzzle(puzzle), + defaultBranchId: + lineResult.contradiction !== blankResult.contradiction + ? lineResult.contradiction + ? 'line' + : 'blank' + : 'line', + branches: [ + buildSlitherInferenceBranch('line', 'Line parity branch', lineBranch.diffs, lineResult), + buildSlitherInferenceBranch('blank', 'Blank parity branch', blankBranch.diffs, blankResult), + ], }) const deriveProbeBudgets = (maxTrialSteps: number): number[] => { @@ -206,6 +230,14 @@ export const createSectorParityInferenceRule = ( diffs: survivingBranch.diffs, affectedCells: [cellKey(candidate.row, candidate.col)], affectedSectors: [candidate.sectorKey], + inferenceDetails: buildInferenceDetails( + puzzle, + 'opposite-branch', + lineBranch.info, + lineResult, + blankBranch.info, + blankResult, + ), } } if (lineResult.contradiction && blankResult.contradiction) { @@ -222,6 +254,14 @@ export const createSectorParityInferenceRule = ( diffs, affectedCells: [cellKey(candidate.row, candidate.col)], affectedSectors: [candidate.sectorKey], + inferenceDetails: buildInferenceDetails( + puzzle, + 'shared-consequence', + lineBranch.info, + lineResult, + blankBranch.info, + blankResult, + ), } } } diff --git a/src/domain/rules/slither/rules/strongInference.ts b/src/domain/rules/slither/rules/strongInference.ts index 63d028a..38c1596 100644 --- a/src/domain/rules/slither/rules/strongInference.ts +++ b/src/domain/rules/slither/rules/strongInference.ts @@ -1,12 +1,12 @@ import { clonePuzzle } from '../../../ir/normalize' import { cellKey, getCornerEdgeKeys, getVertexIncidentEdges, parseSectorKey } from '../../../ir/keys' -import type { Rule, RuleApplication } from '../../types' +import type { InferenceDetails, Rule, RuleApplication } from '../../types' import { SECTOR_MASK_ALL, sectorMaskSingleValue, type PuzzleIR, } from '../../../ir/types' -import { applyEdgeAssumption, runTrialUntilFixpoint, type TrialResult } from './trial' +import { applyEdgeAssumption, buildSlitherInferenceBranch, runTrialUntilFixpoint, type TrialResult } from './trial' import { formatEdgeLabel, formatSectorKeyLabel, formatVertexLabel } from './shared' // const STRONG_MAX_CANDIDATES = 1000 @@ -141,8 +141,35 @@ const immediateContradictionResult = (puzzle: PuzzleIR): TrialResult => ({ kind: 'sector-mask', message: 'setup contradiction: this branch is already incompatible with the current edge state', }, + traceSteps: [], }) +const buildInferenceDetails = ( + puzzle: PuzzleIR, + conclusion: InferenceDetails['conclusion'], + branchAInfo: StrongCandidateBranch, + branchAResult: TrialResult, + branchBInfo: StrongCandidateBranch, + branchBResult: TrialResult, +): InferenceDetails => { + const defaultBranchId = + branchAResult.contradiction !== branchBResult.contradiction + ? branchAResult.contradiction + ? 'a' + : 'b' + : 'a' + return { + kind: 'slither-strong', + conclusion, + basePuzzle: clonePuzzle(puzzle), + defaultBranchId, + branches: [ + buildSlitherInferenceBranch('a', 'Branch A', branchAInfo.diffs, branchAResult), + buildSlitherInferenceBranch('b', 'Branch B', branchBInfo.diffs, branchBResult), + ], + } +} + const deriveProbeBudgets = (maxTrialSteps: number): number[] => { const cappedMax = Math.max(1, maxTrialSteps) const budgets = [24, 96, 384, cappedMax] @@ -315,6 +342,14 @@ export const createStrongInferenceRule = ( diffs, affectedCells: candidate.kind === 'sector-only-one' ? [cellKey(candidate.row, candidate.col)] : [], affectedSectors: candidate.kind === 'sector-only-one' ? [candidate.sectorKey] : [], + inferenceDetails: buildInferenceDetails( + puzzle, + 'opposite-branch', + branchAInfo, + branchAResult, + branchBInfo, + branchBResult, + ), } } if (branchAResult.contradiction && branchBResult.contradiction) { @@ -331,6 +366,14 @@ export const createStrongInferenceRule = ( diffs, affectedCells: candidate.kind === 'sector-only-one' ? [cellKey(candidate.row, candidate.col)] : [], affectedSectors: candidate.kind === 'sector-only-one' ? [candidate.sectorKey] : [], + inferenceDetails: buildInferenceDetails( + puzzle, + 'shared-consequence', + branchAInfo, + branchAResult, + branchBInfo, + branchBResult, + ), } } } diff --git a/src/domain/rules/slither/rules/trial.ts b/src/domain/rules/slither/rules/trial.ts index ead59a4..0d7c6d7 100644 --- a/src/domain/rules/slither/rules/trial.ts +++ b/src/domain/rules/slither/rules/trial.ts @@ -1,6 +1,6 @@ -import { cellKey, edgeKey, getCellEdgeKeys, getCornerEdgeKeys, getVertexIncidentEdges, parseEdgeKey, parseSectorKey } from '../../../ir/keys' +import { cellKey, edgeKey, getCellEdgeKeys, getCornerEdgeKeys, getVertexIncidentEdges, parseEdgeKey, parseSectorKey, vertexKey } from '../../../ir/keys' import { runNextRule } from '../../engine' -import type { Rule } from '../../types' +import type { InferenceBranch, InferenceContradiction, Rule, TrialTraceStep } from '../../types' import { SECTOR_MASK_ALL, sectorMaskAllows, @@ -18,7 +18,7 @@ import { isSlitherCellColor, } from './shared' -export type TrialContradictionReason = { +export type TrialContradictionReason = InferenceContradiction & { kind: | 'vertex-degree' | 'cell-clue' @@ -38,8 +38,23 @@ export type TrialResult = { stepsRun: number elapsedMs: number contradictionReason?: TrialContradictionReason + traceSteps: TrialTraceStep[] } +export const buildSlitherInferenceBranch = ( + id: string, + label: string, + initialDiffs: InferenceBranch['initialDiffs'], + result: TrialResult, +): InferenceBranch => ({ + id, + label, + initialDiffs, + status: result.contradiction ? 'contradiction' : result.exhausted ? 'exhausted' : 'unresolved', + traceSteps: result.traceSteps, + contradiction: result.contradictionReason, +}) + export const applyEdgeAssumption = (puzzle: PuzzleIR, edgeKeyValue: string, to: EdgeMark): boolean => { const current = puzzle.edges[edgeKeyValue]?.mark ?? 'unknown' if (current !== 'unknown') { @@ -67,12 +82,14 @@ const detectVertexContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'vertex-degree', message: `vertex-degree contradiction at ${formatVertexLabel(r, c)}: ${lineCount} line edges meet there`, + vertices: [vertexKey(r, c)], } } if (unknownCount === 0 && lineCount !== 0 && lineCount !== 2) { return { kind: 'vertex-degree', message: `vertex-degree contradiction at ${formatVertexLabel(r, c)}: closed vertex has ${lineCount} line edge`, + vertices: [vertexKey(r, c)], } } } @@ -100,12 +117,14 @@ const detectCellClueContradiction = (puzzle: PuzzleIR): TrialContradictionReason return { kind: 'cell-clue', message: `cell-clue contradiction at ${formatCellLabel(r, c)}: clue ${target} already has ${lineCount} line edges`, + cells: [cellKey(r, c)], } } if (lineCount + unknownCount < target) { return { kind: 'cell-clue', message: `cell-clue contradiction at ${formatCellLabel(r, c)}: clue ${target} can reach at most ${lineCount + unknownCount} line edges`, + cells: [cellKey(r, c)], } } } @@ -120,6 +139,7 @@ const detectSectorContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'sector-mask', message: `sector-mask contradiction at ${formatSectorKeyLabel(sectorKeyValue)}: no corner line count remains allowed`, + sectors: [sectorKeyValue], } } const [row, col, corner] = parseSectorKey(sectorKeyValue) @@ -135,6 +155,7 @@ const detectSectorContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'sector-mask', message: `sector-mask contradiction at ${formatSectorKeyLabel(sectorKeyValue)}: fixed corner has ${lineCount} line edges, which the sector mask forbids`, + sectors: [sectorKeyValue], } } let hasFeasible = false @@ -148,6 +169,7 @@ const detectSectorContradiction = (puzzle: PuzzleIR): TrialContradictionReason | return { kind: 'sector-mask', message: `sector-mask contradiction at ${formatSectorKeyLabel(sectorKeyValue)}: ${lineCount} fixed line edges and ${unknownCount} unknown edges leave no allowed corner count`, + sectors: [sectorKeyValue], } } } @@ -161,6 +183,7 @@ const detectVertexCandidateContradiction = (puzzle: PuzzleIR): TrialContradictio return { kind: 'vertex-candidates', message: `vertex-candidates contradiction at ${formatVertexLabel(row, col)}: no feasible degree state remains`, + vertices: [vertexKeyValue], } } } @@ -184,6 +207,8 @@ const detectColorEdgeContradiction = (puzzle: PuzzleIR): TrialContradictionReaso return { kind: 'color-edge', message: `color-edge contradiction at ${formatEdgeLabel(edgeKeyValue)}: boundary ${mark} requires ${formatCellKeyLabel(adjacentCells[0])} to be ${expected}, but it is ${color}`, + edges: [edgeKeyValue], + cells: adjacentCells, } } continue @@ -200,12 +225,16 @@ const detectColorEdgeContradiction = (puzzle: PuzzleIR): TrialContradictionReaso return { kind: 'color-edge', message: `color-edge contradiction at ${formatEdgeLabel(edgeKeyValue)}: a line edge separates equal-colored cells ${formatCellKeyLabel(adjacentCells[0])} and ${formatCellKeyLabel(adjacentCells[1])}`, + edges: [edgeKeyValue], + cells: adjacentCells, } } if (mark === 'blank' && colorA !== colorB) { return { kind: 'color-edge', message: `color-edge contradiction at ${formatEdgeLabel(edgeKeyValue)}: a blank edge connects different-colored cells ${formatCellKeyLabel(adjacentCells[0])} and ${formatCellKeyLabel(adjacentCells[1])}`, + edges: [edgeKeyValue], + cells: adjacentCells, } } } @@ -295,6 +324,7 @@ const detectLineLoopContradiction = (puzzle: PuzzleIR): TrialContradictionReason closedLoopComponents > 1 ? `line-loop contradiction: ${closedLoopComponents} separate closed loops are present` : `line-loop contradiction: a closed loop of ${closedLoopEdges} edges exists while other line edges remain outside it`, + edges: lineEdges.map(([edgeKeyValue]) => edgeKeyValue), } } return null @@ -354,6 +384,7 @@ const detectDisconnectedGreenContradiction = (puzzle: PuzzleIR): TrialContradict return { kind: 'disconnected-green', message: `disconnected-green contradiction: ${formatCellKeyLabel(disconnectedCell)} cannot connect to ${formatCellKeyLabel(greenCells[0])} through non-line edges`, + cells: [disconnectedCell, greenCells[0]], } } @@ -386,10 +417,12 @@ export const runTrialUntilFixpoint = ( stepsRun: 0, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason: initialContradictionReason, + traceSteps: [], } } let trial = puzzle + const traceSteps: TrialTraceStep[] = [] for (let stepNumber = 1; stepNumber <= maxTrialSteps; stepNumber += 1) { if (Date.now() > deadlineMs) { return { @@ -399,6 +432,7 @@ export const runTrialUntilFixpoint = ( puzzle: trial, stepsRun: stepNumber - 1, elapsedMs: Math.max(0, performance.now() - startedAt), + traceSteps, } } const { nextPuzzle, step } = runNextRule(trial, deterministicRules, stepNumber) @@ -412,9 +446,19 @@ export const runTrialUntilFixpoint = ( stepsRun: stepNumber - 1, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason: contradictionReason ?? undefined, + traceSteps, } } trial = nextPuzzle + traceSteps.push({ + ruleId: step.ruleId, + ruleName: step.ruleName, + message: step.message, + diffs: step.diffs, + affectedCells: step.affectedCells, + affectedEdges: step.affectedEdges, + affectedSectors: step.affectedSectors, + }) const contradictionReason = findHardContradictionReason(trial) if (contradictionReason) { return { @@ -425,6 +469,7 @@ export const runTrialUntilFixpoint = ( stepsRun: stepNumber, elapsedMs: Math.max(0, performance.now() - startedAt), contradictionReason, + traceSteps, } } } @@ -435,5 +480,6 @@ export const runTrialUntilFixpoint = ( puzzle: trial, stepsRun: maxTrialSteps, elapsedMs: Math.max(0, performance.now() - startedAt), + traceSteps, } } diff --git a/src/domain/rules/slither/tests/colorConnectivity.test.ts b/src/domain/rules/slither/tests/colorConnectivity.test.ts new file mode 100644 index 0000000..257b13f --- /dev/null +++ b/src/domain/rules/slither/tests/colorConnectivity.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from 'vitest' +import { decodeSlitherFromPuzzlink } from '../../../parsers/puzzlink' +import { cellKey, edgeKey } from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { runNextRule } from '../../engine' +import { slitherRules } from '../rules' +import { setClue } from './testUtils' + +describe('slither inside reachability coloring rule', () => { + const reachabilityRule = slitherRules.find( + (rule) => rule.id === 'inside-reachability-coloring', + ) + if (!reachabilityRule) { + throw new Error('Expected inside-reachability-coloring rule') + } + + it('colors an unreachable unknown non-3 cell yellow', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = reachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + ]) + }) + + it('does not color reachable unknown cells across unknown or blank edges', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' + + const result = reachabilityRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not cross a line edge', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = reachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + { + kind: 'cell', + cellKey: cellKey(0, 2), + fromFill: null, + toFill: 'yellow', + }, + ]), + ) + }) + + it('does not traverse through existing yellow cells', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + + const result = reachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 2), + fromFill: null, + toFill: 'yellow', + }, + ]) + }) + + it('does not traverse into clue-3 cells and does not color clue-3 cells yellow', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + setClue(puzzle, 0, 1, 3) + + const result = reachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 2), + fromFill: null, + toFill: 'yellow', + }, + ]) + expect(result?.diffs).not.toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('returns null when there are no known green source cells', () => { + const puzzle = createSlitherPuzzle(2, 2) + + const result = reachabilityRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('floods from multiple green source components', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 2)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = reachabilityRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('appears on the provided 19x10 puzzle within the normal solve limit', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/19/10/y13c22d32c1186b8c8b8631d31b13c32czx32c22b21d3376d8d8c7612d32b23b31cw', + ) + let sawReachabilityColoring = false + + for (let stepNumber = 1; stepNumber <= 100; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + if ( + step.ruleId === 'inside-reachability-coloring' && + step.diffs.some( + (diff) => diff.kind === 'cell' && diff.toFill === 'yellow', + ) + ) { + sawReachabilityColoring = true + break + } + current = nextPuzzle + } + + expect(sawReachabilityColoring).toBe(true) + }) +}) + +describe('slither outside reachability coloring rule', () => { + const outsideReachabilityRule = slitherRules.find( + (rule) => rule.id === 'outside-reachability-coloring', + ) + if (!outsideReachabilityRule) { + throw new Error('Expected outside-reachability-coloring rule') + } + + it('colors a fully line-enclosed unknown cell green', () => { + const puzzle = createSlitherPuzzle(1, 1) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + ]) + }) + + it('does not color a boundary cell reachable through an unknown outside edge', () => { + const puzzle = createSlitherPuzzle(1, 1) + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not color a boundary cell reachable through a blank outside edge', () => { + const puzzle = createSlitherPuzzle(1, 1) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('traverses unknown and blank edges but does not cross a line edge', () => { + const puzzle = createSlitherPuzzle(1, 4) + for (let col = 0; col < 3; col += 1) { + puzzle.edges[edgeKey([0, col], [0, col + 1])].mark = 'line' + puzzle.edges[edgeKey([1, col], [1, col + 1])].mark = 'line' + } + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' + puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line' + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }, + ]) + }) + + it('treats existing green cells as traversal blockers without overwriting them', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + ]) + expect(result?.diffs).not.toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: 'green', + toFill: 'green', + }) + }) + + it('does not traverse into clue-3 cells and does not color clue-3 cells green', () => { + const puzzle = createSlitherPuzzle(1, 3) + setClue(puzzle, 0, 1, 3) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + ]) + expect(result?.diffs).not.toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('uses existing yellow cells as outside reachability sources', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + for (let col = 0; col < 3; col += 1) { + puzzle.edges[edgeKey([0, col], [0, col + 1])].mark = 'line' + puzzle.edges[edgeKey([1, col], [1, col + 1])].mark = 'line' + } + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line' + + const result = outsideReachabilityRule.apply(puzzle) + + expect(result).toBeNull() + }) +}) + +describe('slither color connectivity cut coloring rule', () => { + const cutColorRule = slitherRules.find( + (rule) => rule.id === 'color-connectivity-cut-coloring', + ) + if (!cutColorRule) { + throw new Error('Expected color-connectivity-cut-coloring rule') + } + + it('colors an unknown articulation cell green between two green components', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 2)] = { fill: 'green' } + + const result = cutColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + ]) + }) + + it('does not apply when there is only one green source component', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + + const result = cutColorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not color across a line-separated green component', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 2)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line' + + const result = cutColorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('treats an unknown clue-3 cell as a colorable connectivity candidate', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 2)] = { fill: 'green' } + setClue(puzzle, 0, 1, 3) + + const result = cutColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + ]) + }) + + it('colors every unknown cell inside a blank-compressed green bottleneck', () => { + const puzzle = createSlitherPuzzle(1, 4) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 3)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' + + const result = cutColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }, + ]) + }) + + it('colors an outside-to-yellow bottleneck yellow', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(1, 1)] = { fill: 'yellow' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + puzzle.cells[cellKey(1, 2)] = { fill: 'green' } + puzzle.cells[cellKey(2, 1)] = { fill: 'green' } + + const result = cutColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + ]) + }) + + it('colors cells unreachable from green sources yellow', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = cutColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + { + kind: 'cell', + cellKey: cellKey(0, 2), + fromFill: null, + toFill: 'yellow', + }, + ]) + }) + + it('colors cells unreachable from the exterior green without an existing yellow source', () => { + const puzzle = createSlitherPuzzle(1, 1) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = cutColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 0), fromFill: null, toFill: 'green' }, + ]) + }) + + it('keeps unknown edges passable for connectivity reachability', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + + const result = cutColorRule.apply(puzzle) + + expect(result).toBeNull() + }) +}) diff --git a/src/domain/rules/slither/tests/colorPropagation.test.ts b/src/domain/rules/slither/tests/colorPropagation.test.ts new file mode 100644 index 0000000..42824fc --- /dev/null +++ b/src/domain/rules/slither/tests/colorPropagation.test.ts @@ -0,0 +1,759 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, edgeKey, sectorKey } from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { + SECTOR_MASK_ALL, + SECTOR_MASK_NOT_1, + SECTOR_MASK_ONLY_0, + SECTOR_MASK_ONLY_1, + SECTOR_MASK_ONLY_2, +} from '../../../ir/types' +import { slitherRules } from '../rules' +import { setClue } from './testUtils' + +describe('slither color-edge propagation rule', () => { + const colorRule = slitherRules.find( + (rule) => rule.id === 'color-edge-propagation', + ) + if (!colorRule) { + throw new Error('Expected color-edge-propagation rule') + } + + it('marks edge blank when two adjacent cells have same color', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + const between = edgeKey([0, 1], [1, 1]) + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'edge', + edgeKey: between, + from: 'unknown', + to: 'blank', + }) + }) + + it('marks edge line when two adjacent cells have different colors', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + const between = edgeKey([0, 1], [1, 1]) + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'edge', + edgeKey: between, + from: 'unknown', + to: 'line', + }) + }) + + it('marks top boundary edge line when the boundary cell is green', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + const top = edgeKey([0, 1], [0, 2]) + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'edge', + edgeKey: top, + from: 'unknown', + to: 'line', + }) + }) + + it('marks both outer boundary edges line when a corner cell is green', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + const top = edgeKey([0, 0], [0, 1]) + const left = edgeKey([0, 0], [1, 0]) + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, + ]), + ) + }) + + it('marks boundary edges blank when the boundary cell is yellow', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } + const top = edgeKey([0, 0], [0, 1]) + const left = edgeKey([0, 0], [1, 0]) + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { kind: 'edge', edgeKey: top, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' }, + ]), + ) + }) + + it('does not emit a phantom cell diff across an already decided boundary edge', () => { + const puzzle = createSlitherPuzzle(2, 2) + const top = edgeKey([0, 0], [0, 1]) + puzzle.edges[top].mark = 'line' + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + + const result = colorRule.apply(puzzle) + + expect( + result?.diffs.some( + (diff) => diff.kind === 'cell' && diff.cellKey === undefined, + ), + ).not.toBe(true) + }) + + it('infers opposite color across a line edge', () => { + const puzzle = createSlitherPuzzle(2, 2) + const between = edgeKey([0, 1], [1, 1]) + puzzle.edges[between].mark = 'line' + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('infers same color across a blank edge', () => { + const puzzle = createSlitherPuzzle(2, 2) + const between = edgeKey([0, 1], [1, 1]) + puzzle.edges[between].mark = 'blank' + puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } + + const result = colorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }) + }) +}) + +describe('slither color outside seeding rule', () => { + const outsideRule = slitherRules.find( + (rule) => rule.id === 'color-outside-seeding', + ) + if (!outsideRule) { + throw new Error('Expected color-outside-seeding rule') + } + + it('marks boundary-adjacent cell yellow when boundary edge is blank', () => { + const puzzle = createSlitherPuzzle(2, 2) + const topLeftTop = edgeKey([0, 0], [0, 1]) + puzzle.edges[topLeftTop].mark = 'blank' + + const result = outsideRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('marks boundary-adjacent cell green when boundary edge is line', () => { + const puzzle = createSlitherPuzzle(2, 2) + const topLeftTop = edgeKey([0, 0], [0, 1]) + puzzle.edges[topLeftTop].mark = 'line' + + const result = outsideRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'green', + }) + }) + + it('colors a whole boundary-anchored parity component in one application', () => { + const puzzle = createSlitherPuzzle(2, 3) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' + + const result = outsideRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'yellow', + }, + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'green', + }, + { + kind: 'cell', + cellKey: cellKey(0, 2), + fromFill: null, + toFill: 'green', + }, + ]), + ) + }) + + it('does not color an unanchored parity component', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = outsideRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('uses an existing colored cell as a parity component anchor', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = outsideRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + ]) + }) + + it('does not color a component with conflicting anchors', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + + const result = outsideRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('colors multiple anchored components independently', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' + puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line' + + const result = outsideRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'yellow', + }, + { + kind: 'cell', + cellKey: cellKey(1, 1), + fromFill: null, + toFill: 'green', + }, + ]), + ) + }) +}) + +describe('slither color clue propagation rule', () => { + const clueColorRule = slitherRules.find( + (rule) => rule.id === 'color-clue-propagation', + ) + if (!clueColorRule) { + throw new Error('Expected color-clue-propagation rule') + } + + it('colors numbered cell green when clue is less than innercnt', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 1) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + + const result = clueColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('propagates yellow neighbors when yellow numbered cell has clue equal to innercnt', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 1) + puzzle.cells[cellKey(1, 1)] = { + ...puzzle.cells[cellKey(1, 1)], + fill: 'yellow', + } + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + + const result = clueColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'yellow', + }) + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 0), + fromFill: null, + toFill: 'yellow', + }) + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 2), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('propagates green neighbors from two yellow neighbors around a clue 2 without needing the clue cell color', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } + + const result = clueColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'green', + }) + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 2), + fromFill: null, + toFill: 'green', + }) + }) + + it('propagates yellow neighbors from two green neighbors around a clue 2 without needing the clue cell color', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + + const result = clueColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'yellow', + }) + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 2), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('counts out-of-bounds directions as yellow when propagating from a corner clue 2', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 2) + + const result = clueColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 0), + fromFill: null, + toFill: 'green', + }) + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('counts out-of-bounds directions as yellow when coloring a corner numbered cell', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 1) + puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + + const result = clueColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'green', + }) + }) +}) + +describe('slither color orthogonal consensus propagation rule', () => { + const orthogonalColorRule = slitherRules.find( + (rule) => rule.id === 'color-orthogonal-consensus-propagation', + ) + if (!orthogonalColorRule) { + throw new Error('Expected color-orthogonal-consensus-propagation rule') + } + + it('colors an interior unknown cell green when four orthogonal neighbors are green', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(2, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + puzzle.cells[cellKey(1, 2)] = { fill: 'green' } + + const result = orthogonalColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('colors an interior unknown cell yellow when four orthogonal neighbors are yellow', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + puzzle.cells[cellKey(2, 1)] = { fill: 'yellow' } + puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } + puzzle.cells[cellKey(1, 2)] = { fill: 'yellow' } + + const result = orthogonalColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(1, 1), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('treats out-of-bounds orthogonals as yellow for boundary inference', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } + + const result = orthogonalColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 0), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('does not apply when an in-bounds orthogonal neighbor is unknown', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(2, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + + const result = orthogonalColorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not apply when orthogonal neighbors are mixed colors', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(2, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + puzzle.cells[cellKey(1, 2)] = { fill: 'yellow' } + + const result = orthogonalColorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not overwrite an already colored cell', () => { + const puzzle = createSlitherPuzzle(1, 1) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + + const result = orthogonalColorRule.apply(puzzle) + + expect(result).toBeNull() + }) +}) + +describe('slither color sector-mask propagation rule', () => { + const sectorColorRule = slitherRules.find( + (rule) => rule.id === 'color-sector-mask-propagation', + ) + if (!sectorColorRule) { + throw new Error('Expected color-sector-mask-propagation rule') + } + + it('infers same color from notOne sector when one adjacent cell color is known', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 + puzzle.cells[cellKey(1, 2)] = { fill: 'green' } + + const result = sectorColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('infers opposite color from onlyOne sector when one adjacent cell color is known', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.cells[cellKey(1, 2)] = { fill: 'green' } + + const result = sectorColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'yellow', + }) + }) + + it('treats onlyZero and onlyTwo as notOne and infers same color', () => { + const onlyZeroPuzzle = createSlitherPuzzle(3, 3) + onlyZeroPuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = + SECTOR_MASK_ONLY_0 + onlyZeroPuzzle.cells[cellKey(1, 2)] = { fill: 'yellow' } + + const onlyZeroResult = sectorColorRule.apply(onlyZeroPuzzle) + + expect(onlyZeroResult).not.toBeNull() + expect(onlyZeroResult?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'yellow', + }) + + const onlyTwoPuzzle = createSlitherPuzzle(3, 3) + onlyTwoPuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = + SECTOR_MASK_ONLY_2 + onlyTwoPuzzle.cells[cellKey(1, 2)] = { fill: 'green' } + + const onlyTwoResult = sectorColorRule.apply(onlyTwoPuzzle) + + expect(onlyTwoResult).not.toBeNull() + expect(onlyTwoResult?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(2, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('treats out-of-bounds adjacent cell as yellow at boundary', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1 + + const result = sectorColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'green', + }) + }) + + it('infers onlyOne sectors from different diagonal-adjacent colors', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'yellow' } + + const result = sectorColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(0, 0, 'se'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }) + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, 'nw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }) + }) + + it('infers notOne sectors from same diagonal-adjacent colors', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + + const result = sectorColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(0, 0, 'se'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }) + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, 'nw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }) + }) + + it('uses out-of-bounds yellow for color-to-sector propagation at the boundary', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + + const result = sectorColorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(0, 0, 'ne'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }) + expect(result?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(-1, 1, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }) + }) + + it('does not emit redundant or invalid color-to-sector updates for the target sectors', () => { + const compatiblePuzzle = createSlitherPuzzle(3, 3) + compatiblePuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = + SECTOR_MASK_ONLY_1 + compatiblePuzzle.sectors[sectorKey(2, 2, 'nw')].constraintsMask = + SECTOR_MASK_ONLY_1 + compatiblePuzzle.cells[cellKey(1, 2)] = { fill: 'green' } + compatiblePuzzle.cells[cellKey(2, 1)] = { fill: 'yellow' } + + const compatibleResult = sectorColorRule.apply(compatiblePuzzle) + + expect(compatibleResult?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, 'se'), + fromMask: SECTOR_MASK_ONLY_1, + toMask: SECTOR_MASK_ONLY_1, + }) + expect(compatibleResult?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(2, 2, 'nw'), + fromMask: SECTOR_MASK_ONLY_1, + toMask: SECTOR_MASK_ONLY_1, + }) + + const invalidPuzzle = createSlitherPuzzle(3, 3) + invalidPuzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = + SECTOR_MASK_NOT_1 + invalidPuzzle.sectors[sectorKey(2, 2, 'nw')].constraintsMask = + SECTOR_MASK_NOT_1 + invalidPuzzle.cells[cellKey(1, 2)] = { fill: 'green' } + invalidPuzzle.cells[cellKey(2, 1)] = { fill: 'yellow' } + + const invalidResult = sectorColorRule.apply(invalidPuzzle) + + expect(invalidResult?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, 'se'), + fromMask: SECTOR_MASK_NOT_1, + toMask: SECTOR_MASK_ONLY_1, + }) + expect(invalidResult?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(2, 2, 'nw'), + fromMask: SECTOR_MASK_NOT_1, + toMask: SECTOR_MASK_ONLY_1, + }) + }) + + it('does not apply when both adjacent cells are unknown and in bounds', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 + + const result = sectorColorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('skips conflicting color-to-sector inference without reporting an invalid mask', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.sectors[sectorKey(1, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.cells[cellKey(1, 0)] = { fill: 'green' } + + const result = sectorColorRule.apply(puzzle) + + expect(result?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(0, 0, 'se'), + fromMask: SECTOR_MASK_ONLY_1, + toMask: SECTOR_MASK_NOT_1, + }) + expect(result?.diffs).not.toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, 'nw'), + fromMask: SECTOR_MASK_ONLY_1, + toMask: SECTOR_MASK_NOT_1, + }) + }) +}) diff --git a/src/domain/rules/slither/tests/core.test.ts b/src/domain/rules/slither/tests/core.test.ts new file mode 100644 index 0000000..56f99b1 --- /dev/null +++ b/src/domain/rules/slither/tests/core.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, edgeKey, getCellEdgeKeys } from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { slitherRules } from '../rules' +import { setClue, getEdgeDiffKeys } from './testUtils' + +describe('slither cell clue completion rule', () => { + const cellCountRule = slitherRules.find( + (rule) => rule.id === 'cell-count-completion', + ) + if (!cellCountRule) { + throw new Error('Expected cell-count-completion rule') + } + + it('fills remaining unknown edges as blank when clue already has enough lines', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 1) + const [top, bottom, left, right] = getCellEdgeKeys(0, 0) + puzzle.edges[top].mark = 'line' + + const result = cellCountRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual([cellKey(0, 0)]) + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + ]) + }) + + it('fills remaining unknown edges as line when all unknowns are required by clue', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 3) + const [top, bottom, left, right] = getCellEdgeKeys(0, 0) + puzzle.edges[top].mark = 'blank' + + const result = cellCountRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual([cellKey(0, 0)]) + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }, + ]) + }) +}) + +describe('slither prevent premature loop rule', () => { + const antiLoopRule = slitherRules.find( + (rule) => rule.id === 'prevent-premature-loop', + ) + if (!antiLoopRule) { + throw new Error('Expected prevent-premature-loop rule') + } + + it('is ordered after vertex-degree, exact sector edge propagation, coloring, and clue-propagation', () => { + const vertexRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'vertex-degree', + ) + const sectorEdgeRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'sector-constraint-edge-propagation', + ) + const outsideRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-outside-seeding', + ) + const colorRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-edge-propagation', + ) + const clueRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-clue-propagation', + ) + const sectorColorRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-sector-mask-propagation', + ) + const orthogonalConsensusRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-orthogonal-consensus-propagation', + ) + const reachabilityRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'inside-reachability-coloring', + ) + const outsideReachabilityRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'outside-reachability-coloring', + ) + const cutColorRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-connectivity-cut-coloring', + ) + const antiLoopRuleIdx = slitherRules.findIndex( + (rule) => rule.id === 'prevent-premature-loop', + ) + expect(vertexRuleIdx).toBeGreaterThanOrEqual(0) + expect(sectorEdgeRuleIdx).toBe(vertexRuleIdx + 1) + expect(outsideRuleIdx).toBe(sectorEdgeRuleIdx + 1) + expect(colorRuleIdx).toBe(outsideRuleIdx + 1) + expect(clueRuleIdx).toBe(colorRuleIdx + 1) + expect(sectorColorRuleIdx).toBe(clueRuleIdx + 1) + expect(orthogonalConsensusRuleIdx).toBe(sectorColorRuleIdx + 1) + expect(reachabilityRuleIdx).toBe(orthogonalConsensusRuleIdx + 1) + expect(outsideReachabilityRuleIdx).toBe(reachabilityRuleIdx + 1) + expect(cutColorRuleIdx).toBe(outsideReachabilityRuleIdx + 1) + expect(antiLoopRuleIdx).toBe(cutColorRuleIdx + 1) + }) + + it('marks an unknown edge blank when it would close a loop', () => { + const puzzle = createSlitherPuzzle(2, 2) + const top = edgeKey([0, 0], [0, 1]) + const right = edgeKey([0, 1], [1, 1]) + const bottom = edgeKey([1, 0], [1, 1]) + const closing = edgeKey([0, 0], [1, 0]) + puzzle.edges[top].mark = 'line' + puzzle.edges[right].mark = 'line' + puzzle.edges[bottom].mark = 'line' + + const result = antiLoopRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(getEdgeDiffKeys(result)).toEqual([closing]) + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: closing, from: 'unknown', to: 'blank' }, + ]) + }) + + it('marks all loop-closing unknown edges in one application', () => { + const puzzle = createSlitherPuzzle(3, 3) + + const topLeftTop = edgeKey([0, 0], [0, 1]) + const topLeftRight = edgeKey([0, 1], [1, 1]) + const topLeftBottom = edgeKey([1, 0], [1, 1]) + const topLeftClosing = edgeKey([0, 0], [1, 0]) + + const bottomRightTop = edgeKey([2, 2], [2, 3]) + const bottomRightRight = edgeKey([2, 3], [3, 3]) + const bottomRightBottom = edgeKey([3, 2], [3, 3]) + const bottomRightClosing = edgeKey([2, 2], [3, 2]) + + puzzle.edges[topLeftTop].mark = 'line' + puzzle.edges[topLeftRight].mark = 'line' + puzzle.edges[topLeftBottom].mark = 'line' + puzzle.edges[bottomRightTop].mark = 'line' + puzzle.edges[bottomRightRight].mark = 'line' + puzzle.edges[bottomRightBottom].mark = 'line' + + const result = antiLoopRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(getEdgeDiffKeys(result)).toEqual([ + topLeftClosing, + bottomRightClosing, + ]) + expect( + result?.diffs.every( + (d) => d.kind === 'edge' && d.from === 'unknown' && d.to === 'blank', + ), + ).toBe(true) + }) + + it('does not apply when unknown edges do not close a loop', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 1], [2, 1])].mark = 'line' + + const result = antiLoopRule.apply(puzzle) + + expect(result).toBeNull() + }) +}) diff --git a/src/domain/rules/slither/tests/patterns.test.ts b/src/domain/rules/slither/tests/patterns.test.ts new file mode 100644 index 0000000..a003a7c --- /dev/null +++ b/src/domain/rules/slither/tests/patterns.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, it } from 'vitest' +import { decodeSlitherFromPuzzlink } from '../../../parsers/puzzlink' +import { cellKey, edgeKey, parseEdgeKey } from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { runNextRule } from '../../engine' +import { slitherRules } from '../rules' +import { setClue, getEdgeDiffKeys } from './testUtils' + +describe('slither contiguous 3-run boundaries rule', () => { + const threeRunRule = slitherRules.find( + (rule) => rule.id === 'contiguous-three-run-boundaries', + ) + if (!threeRunRule) { + throw new Error('Expected contiguous-three-run-boundaries rule') + } + + it('forces all vertical run boundaries for a horizontal 3-run', () => { + const puzzle = createSlitherPuzzle(4, 5) + setClue(puzzle, 1, 1, 3) + setClue(puzzle, 1, 2, 3) + setClue(puzzle, 1, 3, 3) + + const result = threeRunRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.message).toContain('Contiguous 3-run') + expect(result?.affectedCells).toEqual(['1,1', '1,2', '1,3']) + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([1, 1], [2, 1]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([1, 2], [2, 2]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([1, 3], [2, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([1, 4], [2, 4]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([0, 2], [1, 2]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [3, 2]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([0, 3], [1, 3]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 3], [3, 3]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('forces all horizontal run boundaries for a vertical 3-run', () => { + const puzzle = createSlitherPuzzle(5, 4) + setClue(puzzle, 1, 2, 3) + setClue(puzzle, 2, 2, 3) + setClue(puzzle, 3, 2, 3) + + const result = threeRunRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.message).toContain('Contiguous 3-run') + expect(result?.affectedCells).toEqual(['1,2', '2,2', '3,2']) + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([1, 2], [1, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [2, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([3, 2], [3, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([4, 2], [4, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 1], [2, 2]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 3], [2, 4]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([3, 1], [3, 2]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([3, 3], [3, 4]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('does not apply for an isolated single clue-3 cell', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 3) + + const result = threeRunRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('emits diffs only for unknown edges within a matched run', () => { + const puzzle = createSlitherPuzzle(3, 4) + setClue(puzzle, 0, 1, 3) + setClue(puzzle, 0, 2, 3) + + const alreadyLine = edgeKey([0, 1], [1, 1]) + const blocked = edgeKey([0, 2], [1, 2]) + const unknownEdge = edgeKey([0, 3], [1, 3]) + puzzle.edges[alreadyLine].mark = 'line' + puzzle.edges[blocked].mark = 'blank' + + const result = threeRunRule.apply(puzzle) + + expect(result).not.toBeNull() + const extensionBlank = edgeKey([1, 2], [2, 2]) + expect(result?.affectedCells).toEqual(['0,1', '0,2']) + expect(getEdgeDiffKeys(result)).toEqual([unknownEdge, extensionBlank]) + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: unknownEdge, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: extensionBlank, from: 'unknown', to: 'blank' }, + ]) + }) + + it('only emits in-bounds extension blanks near board edge', () => { + const puzzle = createSlitherPuzzle(5, 4) + setClue(puzzle, 1, 0, 3) + setClue(puzzle, 2, 0, 3) + setClue(puzzle, 3, 0, 3) + + const result = threeRunRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'edge', + edgeKey: edgeKey([2, 1], [2, 2]), + from: 'unknown', + to: 'blank', + }) + expect(result?.diffs).toContainEqual({ + kind: 'edge', + edgeKey: edgeKey([3, 1], [3, 2]), + from: 'unknown', + to: 'blank', + }) + const blankEdgeKeys = + result?.diffs.flatMap((d) => + d.kind === 'edge' && d.to === 'blank' ? [d.edgeKey] : [], + ) ?? [] + expect(blankEdgeKeys).toEqual([ + edgeKey([2, 1], [2, 2]), + edgeKey([3, 1], [3, 2]), + ]) + }) + + it('appears on provided 6x6 puzzle and emits both horizontal/vertical extension blanks', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/6/6/mdidi833dp', + ) + let sawVerticalBlank = false + let sawHorizontalBlank = false + + for (let stepNumber = 1; stepNumber <= 500; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + if (step.ruleId !== 'contiguous-three-run-boundaries') { + current = nextPuzzle + continue + } + + for (const diff of step.diffs) { + if (diff.kind !== 'edge' || diff.to !== 'blank') { + continue + } + const [a, b] = parseEdgeKey(diff.edgeKey) + if (a[0] !== b[0]) { + sawVerticalBlank = true + } else { + sawHorizontalBlank = true + } + } + + if (sawVerticalBlank && sawHorizontalBlank) { + break + } + current = nextPuzzle + } + + expect(sawVerticalBlank).toBe(true) + expect(sawHorizontalBlank).toBe(true) + }) +}) + +describe('slither diagonal adjacent 3 outer corners rule', () => { + const diagonalRule = slitherRules.find( + (rule) => rule.id === 'diagonal-adjacent-three-outer-corners', + ) + if (!diagonalRule) { + throw new Error('Expected diagonal-adjacent-three-outer-corners rule') + } + + it('forces outer-corner edges for main diagonal adjacent 3s', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 0, 0, 3) + setClue(puzzle, 1, 1, 3) + + const result = diagonalRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.message).toContain( + 'Diagonal adjacent 3s force their outside corner edges', + ) + expect(result?.affectedCells).toEqual(['0,0', '1,1']) + expect(getEdgeDiffKeys(result)).toEqual([ + edgeKey([0, 0], [1, 0]), + edgeKey([0, 0], [0, 1]), + edgeKey([1, 2], [2, 2]), + edgeKey([2, 1], [2, 2]), + ]) + }) + + it('forces outer-corner edges for anti diagonal adjacent 3s', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 0, 1, 3) + setClue(puzzle, 1, 0, 3) + + const result = diagonalRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.message).toContain( + 'Diagonal adjacent 3s force their outside corner edges', + ) + expect(result?.affectedCells).toEqual(['0,1', '1,0']) + expect(getEdgeDiffKeys(result)).toEqual([ + edgeKey([0, 1], [0, 2]), + edgeKey([0, 2], [1, 2]), + edgeKey([1, 0], [2, 0]), + edgeKey([2, 0], [2, 1]), + ]) + }) + + it('applies both diagonals in one step when a 2x2 block is all 3s', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 0, 0, 3) + setClue(puzzle, 0, 1, 3) + setClue(puzzle, 1, 0, 3) + setClue(puzzle, 1, 1, 3) + + const result = diagonalRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual(['0,0', '1,1', '0,1', '1,0']) + expect(getEdgeDiffKeys(result)).toEqual([ + edgeKey([0, 0], [1, 0]), + edgeKey([0, 0], [0, 1]), + edgeKey([1, 2], [2, 2]), + edgeKey([2, 1], [2, 2]), + edgeKey([0, 1], [0, 2]), + edgeKey([0, 2], [1, 2]), + edgeKey([1, 0], [2, 0]), + edgeKey([2, 0], [2, 1]), + ]) + expect( + result?.diffs.every( + (d) => d.kind === 'edge' && d.from === 'unknown' && d.to === 'line', + ), + ).toBe(true) + }) + + it('does not apply when 3 clues are not diagonally adjacent', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 0, 0, 3) + setClue(puzzle, 2, 2, 3) + + const result = diagonalRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('emits diffs only for unknown edges when diagonal pattern is matched', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 0, 0, 3) + setClue(puzzle, 1, 1, 3) + + const alreadyLine = edgeKey([0, 0], [1, 0]) + const alreadyBlank = edgeKey([0, 0], [0, 1]) + const unknownA = edgeKey([1, 2], [2, 2]) + const unknownB = edgeKey([2, 1], [2, 2]) + puzzle.edges[alreadyLine].mark = 'line' + puzzle.edges[alreadyBlank].mark = 'blank' + + const result = diagonalRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual(['0,0', '1,1']) + expect(getEdgeDiffKeys(result)).toEqual([unknownA, unknownB]) + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: unknownA, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: unknownB, from: 'unknown', to: 'line' }, + ]) + }) +}) + +describe('slither adjacent 2-3 opposite-cross rule', () => { + const adjacentRule = slitherRules.find( + (rule) => rule.id === 'adjacent-two-three-opposite-cross', + ) + if (!adjacentRule) { + throw new Error('Expected adjacent-two-three-opposite-cross rule') + } + + it('forces the 3 opposite edge and shared-edge extensions for a horizontal 2-3 pair', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 1, 1, 2) + setClue(puzzle, 1, 2, 3) + puzzle.edges[edgeKey([1, 1], [2, 1])].mark = 'blank' + + const result = adjacentRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual([cellKey(1, 1), cellKey(1, 2)]) + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([1, 3], [2, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([0, 2], [1, 2]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [3, 2]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('supports the mirrored horizontal 3-2 order', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 1, 1, 3) + setClue(puzzle, 1, 2, 2) + puzzle.edges[edgeKey([1, 3], [2, 3])].mark = 'blank' + + const result = adjacentRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual([cellKey(1, 2), cellKey(1, 1)]) + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([1, 1], [2, 1]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([0, 2], [1, 2]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [3, 2]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('forces the 3 opposite edge and shared-edge extensions for a vertical 2-3 pair', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 1, 1, 2) + setClue(puzzle, 2, 1, 3) + puzzle.edges[edgeKey([1, 1], [1, 2])].mark = 'blank' + + const result = adjacentRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual([cellKey(1, 1), cellKey(2, 1)]) + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([3, 1], [3, 2]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 0], [2, 1]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [2, 3]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('supports the mirrored vertical 3-2 order', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 1, 1, 3) + setClue(puzzle, 2, 1, 2) + puzzle.edges[edgeKey([3, 1], [3, 2])].mark = 'blank' + + const result = adjacentRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedCells).toEqual([cellKey(2, 1), cellKey(1, 1)]) + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([1, 1], [1, 2]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 0], [2, 1]), + from: 'unknown', + to: 'blank', + }, + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [2, 3]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('only emits in-bounds extension blanks at the boundary', () => { + const puzzle = createSlitherPuzzle(3, 4) + setClue(puzzle, 0, 1, 2) + setClue(puzzle, 0, 2, 3) + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' + + const result = adjacentRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([0, 3], [1, 3]), + from: 'unknown', + to: 'line', + }, + { + kind: 'edge', + edgeKey: edgeKey([1, 2], [2, 2]), + from: 'unknown', + to: 'blank', + }, + ]) + }) + + it('does not apply when the 2 opposite edge is not explicitly blank', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 1, 1, 2) + setClue(puzzle, 1, 2, 3) + + const result = adjacentRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('emits diffs only for unknown target edges', () => { + const puzzle = createSlitherPuzzle(4, 4) + setClue(puzzle, 1, 1, 2) + setClue(puzzle, 1, 2, 3) + puzzle.edges[edgeKey([1, 1], [2, 1])].mark = 'blank' + puzzle.edges[edgeKey([1, 3], [2, 3])].mark = 'line' + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' + + const result = adjacentRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([2, 2], [3, 2]), + from: 'unknown', + to: 'blank', + }, + ]) + }) +}) diff --git a/src/domain/rules/slither/tests/registration.test.ts b/src/domain/rules/slither/tests/registration.test.ts new file mode 100644 index 0000000..38caae8 --- /dev/null +++ b/src/domain/rules/slither/tests/registration.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { deterministicSlitherRules } from '../rules' + +describe('slither deterministic rule order', () => { + it('prioritizes exact sector edge propagation before color and sector inference rules', () => { + const edgePropagationIdx = deterministicSlitherRules.findIndex( + (rule) => rule.id === 'sector-constraint-edge-propagation', + ) + const vertexDegreeIdx = deterministicSlitherRules.findIndex( + (rule) => rule.id === 'vertex-degree', + ) + const colorOutsideIdx = deterministicSlitherRules.findIndex( + (rule) => rule.id === 'color-outside-seeding', + ) + const sectorInferenceIdx = deterministicSlitherRules.findIndex( + (rule) => rule.id === 'sector-inference', + ) + + expect(edgePropagationIdx).toBeGreaterThan(vertexDegreeIdx) + expect(edgePropagationIdx).toBeLessThan(colorOutsideIdx) + expect(edgePropagationIdx).toBeLessThan(sectorInferenceIdx) + }) +}) diff --git a/src/domain/rules/slither/tests/sectorInference.test.ts b/src/domain/rules/slither/tests/sectorInference.test.ts new file mode 100644 index 0000000..5880d77 --- /dev/null +++ b/src/domain/rules/slither/tests/sectorInference.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { decodeSlitherFromPuzzlink } from '../../../parsers/puzzlink' +import { cellKey, edgeKey, sectorKey } from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { + SECTOR_MASK_ALL, + SECTOR_MASK_NOT_0, + SECTOR_MASK_NOT_1, + SECTOR_MASK_ONLY_0, + SECTOR_MASK_ONLY_1, + SECTOR_MASK_ONLY_2, +} from '../../../ir/types' +import { slitherRules } from '../rules' +import { setClue } from './testUtils' + +describe('slither apply sectors rule', () => { + const applySectorsRule = slitherRules.find( + (rule) => rule.id === 'sector-inference', + ) + if (!applySectorsRule) { + throw new Error('Expected sector-inference rule') + } + + it('applies notZero sectors for clue 3 corners', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 3) + puzzle.sectors[sectorKey(0, 1, 'ne')].constraintsMask = SECTOR_MASK_NOT_1 + puzzle.sectors[sectorKey(1, 0, 'sw')].constraintsMask = SECTOR_MASK_NOT_1 + puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_NOT_1 + + const result = applySectorsRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.message).toContain('narrow the allowed corner line counts') + expect(result?.affectedCells).toEqual(['0,0']) + expect(result?.affectedSectors).toEqual([ + sectorKey(0, 0, 'nw'), + sectorKey(0, 0, 'ne'), + sectorKey(0, 0, 'sw'), + sectorKey(0, 0, 'se'), + ]) + expect(result?.diffs.every((d) => d.kind === 'sector')).toBe(true) + expect(result?.diffs).toEqual([ + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'nw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_2, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'ne'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_0, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_0, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'se'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_0, + }, + ]) + }) + + it('applies onlyOne mask when corner has one line and one blank edge', () => { + const puzzle = createSlitherPuzzle(2, 3) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' + + const result = applySectorsRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.affectedSectors).toContain(sectorKey(0, 0, 'ne')) + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(0, 0, 'ne'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }) + }) + + it('infers onlyOne for (0,3) nw on a boundary vertex with three incident edges (puzz.link 4×4)', () => { + const puzzle = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/4/4/183aibi', + ) + expect(puzzle.cells[cellKey(0, 3)]?.clue).toEqual({ + kind: 'number', + value: 3, + }) + + // Vertex (0,3) has only three incident edges; the edge west of that vertex is outside the + // (0,3)-nw sector. One line there forces the sector to contribute exactly one line (step 1.2). + const westOfVertex = edgeKey([0, 2], [0, 3]) + puzzle.edges[westOfVertex].mark = 'line' + + const result = applySectorsRule.apply(puzzle) + + expect(result).not.toBeNull() + const nw03 = sectorKey(0, 3, 'nw') + expect(result?.affectedSectors).toContain(nw03) + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: nw03, + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }) + }) + + it('tightens board-corner sectors to notOne from natural boundary geometry', () => { + const puzzle = createSlitherPuzzle(2, 2) + + const result = applySectorsRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'nw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 1, 'ne'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + { + kind: 'sector', + sectorKey: sectorKey(1, 0, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + { + kind: 'sector', + sectorKey: sectorKey(1, 1, 'se'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + ]), + ) + }) + + it('does not relax already-strong corner sector masks', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_0 + puzzle.sectors[sectorKey(0, 1, 'ne')].constraintsMask = SECTOR_MASK_ONLY_2 + puzzle.sectors[sectorKey(1, 0, 'sw')].constraintsMask = SECTOR_MASK_ONLY_0 + puzzle.sectors[sectorKey(1, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_2 + + const result = applySectorsRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('tightens edge non-corner sector to notOne when its only non-sector edge is blank', () => { + const puzzle = createSlitherPuzzle(3, 3) + const nonSectorEdge = edgeKey([0, 0], [0, 1]) + puzzle.edges[nonSectorEdge].mark = 'blank' + + const result = applySectorsRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(0, 1, 'nw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }) + }) + + it('does not relax already-strong edge non-corner sector masks under boundary blank evidence', () => { + const puzzle = createSlitherPuzzle(3, 3) + const nonSectorEdge = edgeKey([0, 0], [0, 1]) + puzzle.edges[nonSectorEdge].mark = 'blank' + const targetSector = sectorKey(0, 1, 'nw') + puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_ONLY_0 + + const result = applySectorsRule.apply(puzzle) + + expect(result).not.toBeNull() + expect( + result?.diffs.some( + (d) => d.kind === 'sector' && d.sectorKey === targetSector, + ), + ).toBe(false) + }) + + it('returns null when sectors are already up to date', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 3) + const first = applySectorsRule.apply(puzzle) + if (!first) { + throw new Error('Expected first apply-sectors result') + } + for (const diff of first.diffs) { + if (diff.kind === 'sector') { + puzzle.sectors[diff.sectorKey].constraintsMask = diff.toMask + } + } + + const second = applySectorsRule.apply(puzzle) + + expect(second).toBeNull() + }) +}) diff --git a/src/domain/rules/slither/tests/sectorParityInference.test.ts b/src/domain/rules/slither/tests/sectorParityInference.test.ts new file mode 100644 index 0000000..9d5a137 --- /dev/null +++ b/src/domain/rules/slither/tests/sectorParityInference.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' +import { + cellKey, + edgeKey, + getCornerEdgeKeys, + sectorKey, +} from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { SECTOR_MASK_NOT_1 } from '../../../ir/types' +import type { Rule } from '../../types' +import { slitherRules } from '../rules' +import { createSectorParityInferenceRule } from '../rules/sectorParityInference' +import { setClue } from './testUtils' + +describe('slither sector parity inference rule', () => { + const sectorParityRule = slitherRules.find( + (rule) => rule.id === 'sector-parity-inference', + ) + if (!sectorParityRule) { + throw new Error('Expected sector-parity-inference rule') + } + + it('places sector parity inference after color assumption and before strong inference', () => { + const colorAssumptionIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-assumption-inference', + ) + const sectorParityIdx = slitherRules.findIndex( + (rule) => rule.id === 'sector-parity-inference', + ) + const strongIdx = slitherRules.findIndex( + (rule) => rule.id === 'strong-inference', + ) + + expect(colorAssumptionIdx).toBeGreaterThanOrEqual(0) + expect(sectorParityIdx).toBe(colorAssumptionIdx + 1) + expect(strongIdx).toBe(sectorParityIdx + 1) + }) + + it('forces both notOne sector edges blank when the both-line branch creates a closed subloop', () => { + const directSectorParityRule = createSectorParityInferenceRule(() => []) + const puzzle = createSlitherPuzzle(2, 2) + const targetSector = sectorKey(0, 0, 'se') + puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1 + const [bottom, right] = getCornerEdgeKeys(0, 0, 'se') + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line' + + const result = directSectorParityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + ]) + expect(result?.affectedSectors).toEqual([targetSector]) + expect(result?.message).toContain('cannot have exactly one line') + expect(result?.message).toContain('contradicts the puzzle') + }) + + it('forces both notOne sector edges line when the both-blank branch violates a clue', () => { + const directSectorParityRule = createSectorParityInferenceRule(() => []) + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 2) + const targetSector = sectorKey(0, 0, 'se') + puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1 + const [bottom, right] = getCornerEdgeKeys(0, 0, 'se') + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'blank' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'blank' + + const result = directSectorParityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }, + ]) + expect(result?.affectedSectors).toEqual([targetSector]) + expect(result?.message).toContain('contradicts the puzzle') + }) + + it('forces a sector parity branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { + const puzzle = createSlitherPuzzle(1, 1) + setClue(puzzle, 0, 0, 2) + const targetSector = sectorKey(0, 0, 'nw') + puzzle.sectors[targetSector].constraintsMask = SECTOR_MASK_NOT_1 + const [top, left] = getCornerEdgeKeys(0, 0, 'nw') + const bottom = edgeKey([1, 0], [1, 1]) + const right = edgeKey([0, 1], [1, 1]) + const stallRule: Rule = { + id: 'sector-parity-stall-test', + name: 'Sector Parity Stall Test', + apply: (trial) => { + const topMark = trial.edges[top]?.mark ?? 'unknown' + const leftMark = trial.edges[left]?.mark ?? 'unknown' + if (topMark === 'blank' && leftMark === 'blank') { + return { + message: 'make blank branch contradict the clue', + diffs: [ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + ], + affectedCells: [cellKey(0, 0)], + } + } + if (topMark !== 'line' || leftMark !== 'line') { + return null + } + return { + message: 'keep line branch unresolved', + diffs: [ + { + kind: 'sector', + sectorKey: targetSector, + fromMask: trial.sectors[targetSector].constraintsMask, + toMask: trial.sectors[targetSector].constraintsMask, + }, + ], + affectedCells: [cellKey(0, 0)], + affectedSectors: [targetSector], + } + }, + } + const probingSectorParityRule = createSectorParityInferenceRule( + () => [stallRule], + { + maxMs: Number.POSITIVE_INFINITY, + maxTrialSteps: 24, + }, + ) + + const result = probingSectorParityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, + ]) + expect(result?.message).toContain('probe budget 24') + expect(result?.message).toContain('line branch unresolved after 24 steps') + expect(result?.message).toContain('blank branch contradicted after 1 step') + expect(result?.inferenceDetails).toMatchObject({ + kind: 'slither-sector-parity', + defaultBranchId: 'blank', + }) + expect(result?.inferenceDetails?.branches).toHaveLength(2) + }) + + it('checks later sector parity candidates at the first probe budget', () => { + const puzzle = createSlitherPuzzle(2, 1) + const firstSector = sectorKey(0, 0, 'nw') + const secondSector = sectorKey(1, 0, 'nw') + puzzle.sectors[firstSector].constraintsMask = SECTOR_MASK_NOT_1 + puzzle.sectors[secondSector].constraintsMask = SECTOR_MASK_NOT_1 + setClue(puzzle, 1, 0, 2) + const bottom = edgeKey([2, 0], [2, 1]) + const right = edgeKey([1, 1], [2, 1]) + const [firstA, firstB] = getCornerEdgeKeys(0, 0, 'nw') + const [secondA, secondB] = getCornerEdgeKeys(1, 0, 'nw') + const stallRule: Rule = { + id: 'sector-parity-first-candidate-stall-test', + name: 'Sector Parity First Candidate Stall Test', + apply: (trial) => { + if ( + (trial.edges[secondA]?.mark ?? 'unknown') === 'blank' && + (trial.edges[secondB]?.mark ?? 'unknown') === 'blank' + ) { + return { + message: 'make second blank branch contradict the clue', + diffs: [ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + ], + affectedCells: [cellKey(1, 0)], + } + } + if ((trial.edges[firstA]?.mark ?? 'unknown') === 'unknown') { + return null + } + if ((trial.edges[firstB]?.mark ?? 'unknown') === 'unknown') { + return null + } + return { + message: 'keep first candidate unresolved', + diffs: [ + { + kind: 'sector', + sectorKey: firstSector, + fromMask: trial.sectors[firstSector].constraintsMask, + toMask: trial.sectors[firstSector].constraintsMask, + }, + ], + affectedCells: [cellKey(0, 0)], + affectedSectors: [firstSector], + } + }, + } + const probingSectorParityRule = createSectorParityInferenceRule( + () => [stallRule], + { + maxMs: Number.POSITIVE_INFINITY, + maxTrialSteps: 24, + }, + ) + + const result = probingSectorParityRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: secondA, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: secondB, from: 'unknown', to: 'line' }, + ]) + expect(result?.affectedSectors).toEqual([secondSector]) + expect(result?.message).toContain('probe budget 24') + }) + + it('returns null when both notOne parity branches remain feasible', () => { + const directSectorParityRule = createSectorParityInferenceRule(() => []) + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_NOT_1 + + const result = directSectorParityRule.apply(puzzle) + + expect(result).toBeNull() + }) +}) diff --git a/src/domain/rules/slither/tests/sectorPropagation.test.ts b/src/domain/rules/slither/tests/sectorPropagation.test.ts new file mode 100644 index 0000000..1babfe4 --- /dev/null +++ b/src/domain/rules/slither/tests/sectorPropagation.test.ts @@ -0,0 +1,792 @@ +import { describe, expect, it } from 'vitest' +import { decodeSlitherFromPuzzlink } from '../../../parsers/puzzlink' +import { + cellKey, + edgeKey, + getCellEdgeKeys, + getCornerEdgeKeys, + getVertexIncidentEdges, + parseEdgeKey, + sectorKey, + vertexKey, +} from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import { + SECTOR_MASK_ALL, + SECTOR_MASK_NOT_0, + SECTOR_MASK_NOT_1, + SECTOR_MASK_NOT_2, + SECTOR_MASK_ONLY_0, + SECTOR_MASK_ONLY_1, + SECTOR_MASK_ONLY_2, +} from '../../../ir/types' +import { runNextRule } from '../../engine' +import { slitherRules } from '../rules' +import { setClue } from './testUtils' + +describe('slither sector notOne clue-2 propagation rule', () => { + const propagationRule = slitherRules.find( + (rule) => rule.id === 'sector-not-one-clue-two-propagation', + ) + if (!propagationRule) { + throw new Error('Expected sector-not-one-clue-two-propagation rule') + } + + it('marks target corner edges blank when clue=2, target sector is notOne, and opposite corner has a line', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + const [seTopOrBottom] = getCornerEdgeKeys(0, 0, 'se') + puzzle.edges[seTopOrBottom].mark = 'line' + + const result = propagationRule.apply(puzzle) + + expect(result).not.toBeNull() + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: nwTop, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }, + ]) + expect(result?.affectedCells).toEqual([cellKey(0, 0)]) + expect(result?.affectedSectors).toEqual([ + sectorKey(0, 0, 'nw'), + sectorKey(0, 0, 'se'), + ]) + }) + + it('does not apply when clue is not 2', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 3) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + const [seEdge] = getCornerEdgeKeys(0, 0, 'se') + puzzle.edges[seEdge].mark = 'line' + + const result = propagationRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not apply when opposite corner has no line', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + + const result = propagationRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('is idempotent when target corner edges are already decided', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + const [seEdge] = getCornerEdgeKeys(0, 0, 'se') + puzzle.edges[seEdge].mark = 'line' + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + puzzle.edges[nwTop].mark = 'blank' + puzzle.edges[nwLeft].mark = 'blank' + + const result = propagationRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('appears during stepwise solving for the provided 10x10 puzzle', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/10/10/zsan23dzzq', + ) + let triggered = false + + for (let stepNumber = 1; stepNumber <= 600; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + if ( + step.ruleId === 'sector-not-one-clue-two-propagation' || + step.ruleId === 'clue-vertex-candidate-combination-pruning' + ) { + triggered = true + break + } + current = nextPuzzle + } + + expect(triggered).toBe(true) + }) +}) + +describe('slither sector diagonal shared-vertex propagation rule', () => { + const diagonalSectorRule = slitherRules.find( + (rule) => rule.id === 'sector-diagonal-shared-vertex-propagation', + ) + if (!diagonalSectorRule) { + throw new Error('Expected sector-diagonal-shared-vertex-propagation rule') + } + + it('propagates onlyOne from A.ne to B.sw', () => { + const puzzle = createSlitherPuzzle(4, 4) + puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1 + + const result = diagonalSectorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'sector', + sectorKey: sectorKey(1, 3, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }, + ]) + expect(result?.affectedCells).toEqual([cellKey(2, 2), cellKey(1, 3)]) + expect(result?.affectedSectors).toEqual([ + sectorKey(2, 2, 'ne'), + sectorKey(1, 3, 'sw'), + ]) + }) + + it('propagates notOne from A.ne to B.sw', () => { + const puzzle = createSlitherPuzzle(4, 4) + puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_1 + + const result = diagonalSectorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'sector', + sectorKey: sectorKey(1, 3, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + ]) + }) + + it('propagates notZero from A.ne to B.sw as notTwo', () => { + const puzzle = createSlitherPuzzle(4, 4) + puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_0 + + const result = diagonalSectorRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'sector', + sectorKey: sectorKey(1, 3, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_2, + }, + ]) + }) + + it('does not apply when diagonal target cell is out of bounds', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + + const result = diagonalSectorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('is idempotent when target sector is already equally constrained', () => { + const puzzle = createSlitherPuzzle(4, 4) + puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_0 + puzzle.sectors[sectorKey(1, 3, 'sw')].constraintsMask = SECTOR_MASK_NOT_2 + + const result = diagonalSectorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('skips conflicts when intersection would become zero', () => { + const puzzle = createSlitherPuzzle(4, 4) + puzzle.sectors[sectorKey(2, 2, 'ne')].constraintsMask = SECTOR_MASK_NOT_0 + puzzle.sectors[sectorKey(1, 3, 'sw')].constraintsMask = SECTOR_MASK_ONLY_2 + + const result = diagonalSectorRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('appears during stepwise solving for the provided 8x8 puzzle', () => { + const rulesWithoutCutColoring = slitherRules.filter( + (rule) => rule.id !== 'color-connectivity-cut-coloring', + ) + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/8/8/gdg1dddbdid26d72ccicadc3cgc', + ) + let triggered = false + + for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + rulesWithoutCutColoring, + stepNumber, + ) + if (!step) { + break + } + if (step.ruleId === 'sector-diagonal-shared-vertex-propagation') { + triggered = true + break + } + current = nextPuzzle + } + + expect(triggered).toBe(true) + }) + + it('appears or becomes unnecessary during stepwise solving for the provided 10x10 puzzle', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/10/10/ga337ddkdh2adbgdi20dp23dibgbd0dhdkd511da', + ) + let triggered = false + + for (let stepNumber = 1; stepNumber <= 1200; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + if (step.ruleId === 'sector-diagonal-shared-vertex-propagation') { + triggered = true + break + } + current = nextPuzzle + } + + expect(triggered || diagonalSectorRule.apply(current) === null).toBe(true) + }) +}) + +describe('slither vertex candidate edge pruning rule', () => { + const vertexRule = slitherRules.find( + (rule) => rule.id === 'vertex-candidate-edge-pruning', + ) + if (!vertexRule) { + throw new Error('Expected vertex-candidate-edge-pruning rule') + } + + it('prunes vertex candidates from known line and blank edges and forces the remaining continuation', () => { + const puzzle = createSlitherPuzzle(2, 2) + const [up, down, left, right] = getVertexIncidentEdges( + 1, + 1, + puzzle.rows, + puzzle.cols, + ) + puzzle.edges[up].mark = 'line' + puzzle.edges[down].mark = 'blank' + puzzle.edges[left].mark = 'blank' + + const result = vertexRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'vertex', + vertexKey: vertexKey(1, 1), + fromCandidates: puzzle.vertices[vertexKey(1, 1)].candidateEdgeSets, + toCandidates: [[right, up].sort()], + }) + expect(result?.diffs).toContainEqual({ + kind: 'edge', + edgeKey: right, + from: 'unknown', + to: 'line', + }) + }) +}) + +describe('slither clue vertex-candidate combination pruning rule', () => { + const combinationRule = slitherRules.find( + (rule) => rule.id === 'clue-vertex-candidate-combination-pruning', + ) + if (!combinationRule) { + throw new Error('Expected clue-vertex-candidate-combination-pruning rule') + } + + it('prunes clue-0 corners to onlyZero sector masks', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 0) + + const result = combinationRule.apply(puzzle) + + expect(result).not.toBeNull() + for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, corner), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_0, + }) + } + expect(result?.diffs.some((diff) => diff.kind === 'vertex')).toBe(true) + }) + + it('prunes clue-1 corners to notTwo sector masks', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 1) + + const result = combinationRule.apply(puzzle) + + expect(result).not.toBeNull() + for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, corner), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_2, + }) + } + }) + + it('prunes clue-2 boundary corners using the full four-corner candidate check', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 0, 0, 2) + + const result = combinationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'nw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'ne'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'sw'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_ONLY_1, + }, + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'se'), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_1, + }, + ]), + ) + }) + + it('prunes clue-3 corners to notZero sector masks', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 1, 1, 3) + + const result = combinationRule.apply(puzzle) + + expect(result).not.toBeNull() + for (const corner of ['nw', 'ne', 'sw', 'se'] as const) { + expect(result?.diffs).toContainEqual({ + kind: 'sector', + sectorKey: sectorKey(1, 1, corner), + fromMask: SECTOR_MASK_ALL, + toMask: SECTOR_MASK_NOT_0, + }) + } + }) + + it('removes a vertex candidate that is locally legal but unsupported by a neighboring clue', () => { + const puzzle = createSlitherPuzzle(3, 3) + setClue(puzzle, 0, 0, 0) + const [down, right] = getVertexIncidentEdges(0, 0, puzzle.rows, puzzle.cols) + + const result = combinationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toContainEqual({ + kind: 'vertex', + vertexKey: vertexKey(0, 0), + fromCandidates: [[], [down, right].sort()], + toCandidates: [[]], + }) + }) + + it('appears during stepwise solving for the provided 5x5 line-case puzzle', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/5/5/hdhdhcp', + ) + let triggered = false + + for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + if (step.ruleId === 'clue-vertex-candidate-combination-pruning') { + triggered = true + break + } + current = nextPuzzle + } + + expect(triggered).toBe(true) + }) +}) + +describe('slither sector constraint edge propagation rule', () => { + const edgePropagationRule = slitherRules.find( + (rule) => rule.id === 'sector-constraint-edge-propagation', + ) + if (!edgePropagationRule) { + throw new Error('Expected sector-constraint-edge-propagation rule') + } + + it('forces both corner edges to line when sector mask is onlyTwo', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_2 + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + + const result = edgePropagationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: nwTop, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }, + ]) + }) + + it('forces both corner edges to blank when sector mask is onlyZero', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_0 + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + + const result = edgePropagationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: nwTop, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }, + ]) + }) + + it('propagates the provided 10x10 top-edge onlyTwo sector to lines immediately after inference', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/10/10/g88b227637bg2067a7bj8c6a223c1adh1cb1bi32di1dc33783', + ) + const targetSector = sectorKey(0, 1, 'ne') + const targetEdges = getCornerEdgeKeys(0, 1, 'ne') + let sawOnlyTwoInference = false + + for (let stepNumber = 1; stepNumber <= 80; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + + if (!sawOnlyTwoInference) { + const sectorDiff = step.diffs.find( + (d) => d.kind === 'sector' && d.sectorKey === targetSector, + ) + if ( + sectorDiff?.kind === 'sector' && + sectorDiff.toMask === SECTOR_MASK_ONLY_2 + ) { + if ( + targetEdges.every((edge) => nextPuzzle.edges[edge]?.mark === 'line') + ) { + return + } + sawOnlyTwoInference = true + current = nextPuzzle + continue + } + } else { + expect(step.ruleId).toBe('sector-constraint-edge-propagation') + expect(step.diffs).toEqual( + expect.arrayContaining( + targetEdges.map((edge) => ({ + kind: 'edge' as const, + edgeKey: edge, + from: 'unknown' as const, + to: 'line' as const, + })), + ), + ) + return + } + + current = nextPuzzle + } + + throw new Error( + 'Expected sector inference followed by immediate edge propagation for (R1, C2, NE).', + ) + }) + + it('forces the last unknown corner edge to line when onlyOne with one blank', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + puzzle.edges[nwTop].mark = 'blank' + + const result = edgePropagationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }, + ]) + }) + + it('forces the last unknown corner edge to line when notOne already has one line', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + puzzle.edges[nwTop].mark = 'line' + + const result = edgePropagationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'line' }, + ]) + }) + + it('forces the last unknown corner edge to blank when notOne already has one blank', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + puzzle.edges[nwTop].mark = 'blank' + + const result = edgePropagationRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: nwLeft, from: 'unknown', to: 'blank' }, + ]) + }) + + it('does not emit a notOne propagation diff when both corner edges are already decided', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + puzzle.edges[nwTop].mark = 'line' + puzzle.edges[nwLeft].mark = 'blank' + + const result = edgePropagationRule.apply(puzzle) + + expect(result).toBeNull() + }) +}) + +describe('slither sector clue-1/3 onlyOne opposite edges rule', () => { + const clueOneThreeRule = slitherRules.find( + (rule) => rule.id === 'sector-clue-one-three-intra-cell-propagation', + ) + if (!clueOneThreeRule) { + throw new Error( + 'Expected sector-clue-one-three-intra-cell-propagation rule', + ) + } + + it('forces the two edges not in the sector to blank when clue is 1 and sector is onlyOne', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 1) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + const cellEdges = getCellEdgeKeys(0, 0) + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + const opposite = cellEdges.filter((e) => e !== nwTop && e !== nwLeft) + + const result = clueOneThreeRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(opposite).toHaveLength(2) + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { kind: 'edge', edgeKey: opposite[0], from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: opposite[1], from: 'unknown', to: 'blank' }, + ]), + ) + expect(result?.diffs).toHaveLength(2) + expect(result?.affectedCells).toEqual(['0,0']) + expect(result?.affectedSectors).toContain(sectorKey(0, 0, 'nw')) + }) + + it('forces the two edges not in the sector to line when clue is 3 and sector is onlyOne', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 3) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + const cellEdges = getCellEdgeKeys(0, 0) + const [nwTop, nwLeft] = getCornerEdgeKeys(0, 0, 'nw') + const opposite = cellEdges.filter((e) => e !== nwTop && e !== nwLeft) + + const result = clueOneThreeRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual( + expect.arrayContaining([ + { kind: 'edge', edgeKey: opposite[0], from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: opposite[1], from: 'unknown', to: 'line' }, + ]), + ) + expect(result?.diffs).toHaveLength(2) + }) +}) + +describe('slither vertex onlyOne non-sector balance rule', () => { + const vertexBalanceRule = slitherRules.find( + (rule) => rule.id === 'vertex-onlyone-non-sector-balance', + ) + if (!vertexBalanceRule) { + throw new Error('Expected vertex-onlyone-non-sector-balance rule') + } + + it('forces the other non-sector edge to line when one non-sector edge is blank', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 + const bottom = edgeKey([1, 1], [2, 1]) + const right = edgeKey([1, 1], [1, 2]) + puzzle.edges[bottom].mark = 'blank' + + const result = vertexBalanceRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'line' }, + ]) + expect(result?.affectedSectors).toContain(sectorKey(0, 0, 'se')) + }) + + it('forces the other non-sector edge to blank when one non-sector edge is line', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 + const bottom = edgeKey([1, 1], [2, 1]) + const right = edgeKey([1, 1], [1, 2]) + puzzle.edges[right].mark = 'line' + + const result = vertexBalanceRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + ]) + }) + + it('supports diagonal-sector narrative: onlyOne on one diagonal plus blank on opposite diagonal edge forces line', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 1, 'sw')].constraintsMask = SECTOR_MASK_ONLY_1 + + const oppositeDiagonalEdgeA = edgeKey([1, 0], [1, 1]) + const oppositeDiagonalEdgeB = edgeKey([1, 1], [2, 1]) + puzzle.edges[oppositeDiagonalEdgeA].mark = 'blank' + + const result = vertexBalanceRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: oppositeDiagonalEdgeB, + from: 'unknown', + to: 'line', + }, + ]) + expect(result?.affectedSectors).toContain(sectorKey(0, 1, 'sw')) + }) + + it('forces the single non-sector boundary edge to line when a boundary corner sector is onlyOne', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + + const result = vertexBalanceRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([0, 0], [0, 1]), + from: 'unknown', + to: 'line', + }, + ]) + expect(result?.affectedSectors).toContain(sectorKey(0, 1, 'nw')) + }) + + it('is idempotent on boundary single non-sector case when edge is already decided', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + const forced = edgeKey([0, 0], [0, 1]) + puzzle.edges[forced].mark = 'line' + + const result = vertexBalanceRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('does not force boundary non-sector edge when sector is not onlyOne', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_NOT_1 + + const result = vertexBalanceRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('leaves the provided 5x5 cgcx boundary line to color-edge propagation during stepwise solving', () => { + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/5/5/cgcx', + ) + let colorEdgeTriggered = false + let sawBoundaryLine = false + + for (let stepNumber = 1; stepNumber <= 1000; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + slitherRules, + stepNumber, + ) + if (!step) { + break + } + if (step.ruleId === 'color-edge-propagation') { + for (const diff of step.diffs) { + if (diff.kind !== 'edge' || diff.to !== 'line') { + continue + } + const [a, b] = parseEdgeKey(diff.edgeKey) + const isBoundary = + (a[0] === 0 && b[0] === 0) || + (a[0] === current.rows && b[0] === current.rows) || + (a[1] === 0 && b[1] === 0) || + (a[1] === current.cols && b[1] === current.cols) + if (isBoundary) { + sawBoundaryLine = true + break + } + } + if (sawBoundaryLine) { + colorEdgeTriggered = true + break + } + } + current = nextPuzzle + } + + expect(colorEdgeTriggered).toBe(true) + expect(sawBoundaryLine).toBe(true) + }) +}) diff --git a/src/domain/rules/slither/tests/strongInference.test.ts b/src/domain/rules/slither/tests/strongInference.test.ts new file mode 100644 index 0000000..ad24edc --- /dev/null +++ b/src/domain/rules/slither/tests/strongInference.test.ts @@ -0,0 +1,674 @@ +import { describe, expect, it } from 'vitest' +import { decodeSlitherFromPuzzlink } from '../../../parsers/puzzlink' +import { + cellKey, + edgeKey, + getCornerEdgeKeys, + sectorKey, + vertexKey, +} from '../../../ir/keys' +import { clonePuzzle } from '../../../ir/normalize' +import { createSlitherPuzzle } from '../../../ir/slither' +import { SECTOR_MASK_ONLY_1 } from '../../../ir/types' +import { applyRuleDiffs, runNextRule } from '../../engine' +import type { Rule } from '../../types' +import { slitherRules } from '../rules' +import { createColorAssumptionInferenceRule } from '../rules/colorAssumptionInference' +import { createStrongInferenceRule } from '../rules/strongInference' +import { + findHardContradictionReason, + runTrialUntilFixpoint, +} from '../rules/trial' +import { setClue } from './testUtils' + +describe('slither inference branch details', () => { + it('records the provided strong inference branch trace without changing the formal conclusion', () => { + const url = + 'https://puzz.link/p?slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' + let puzzle = decodeSlitherFromPuzzlink(url) + let targetStep: ReturnType['step'] = null + + for (let stepNumber = 1; stepNumber <= 3; stepNumber += 1) { + const result = runNextRule(puzzle, slitherRules, stepNumber) + puzzle = result.nextPuzzle + targetStep = result.step + } + + expect(targetStep?.ruleId).toBe('strong-inference') + expect(targetStep?.diffs).toEqual([ + { + kind: 'edge', + edgeKey: edgeKey([1, 2], [1, 3]), + from: 'unknown', + to: 'blank', + }, + ]) + expect(targetStep?.inferenceDetails?.defaultBranchId).toBe('a') + const failingBranch = targetStep?.inferenceDetails?.branches[0] + expect(failingBranch).toMatchObject({ + status: 'contradiction', + initialDiffs: [ + { + kind: 'edge', + edgeKey: edgeKey([1, 2], [1, 3]), + from: 'unknown', + to: 'line', + }, + ], + }) + expect(failingBranch?.traceSteps).toHaveLength(9) + expect(failingBranch?.contradiction).toMatchObject({ + kind: 'vertex-degree', + vertices: [vertexKey(5, 3)], + }) + + let rebuilt = applyRuleDiffs( + targetStep?.inferenceDetails?.basePuzzle ?? puzzle, + failingBranch?.initialDiffs ?? [], + ) + for (const traceStep of failingBranch?.traceSteps ?? []) { + rebuilt = applyRuleDiffs(rebuilt, traceStep.diffs) + } + expect(findHardContradictionReason(rebuilt)?.kind).toBe('vertex-degree') + }) +}) + +describe('slither strong inference rule', () => { + const colorAssumptionRule = slitherRules.find( + (rule) => rule.id === 'color-assumption-inference', + ) + if (!colorAssumptionRule) { + throw new Error('Expected color-assumption-inference rule') + } + const unboundedColorAssumptionRule = createColorAssumptionInferenceRule( + () => + slitherRules.filter( + (rule) => + rule.id !== 'color-assumption-inference' && + rule.id !== 'sector-parity-inference' && + rule.id !== 'strong-inference', + ), + { maxMs: Number.POSITIVE_INFINITY }, + ) + const strongRule = slitherRules.find((rule) => rule.id === 'strong-inference') + if (!strongRule) { + throw new Error('Expected strong-inference rule') + } + const unboundedStrongRule = createStrongInferenceRule( + () => + slitherRules.filter( + (rule) => + rule.id !== 'color-assumption-inference' && + rule.id !== 'sector-parity-inference' && + rule.id !== 'strong-inference', + ), + { maxMs: Number.POSITIVE_INFINITY }, + ) + + it('places color assumption inference before strong inference', () => { + const colorAssumptionIdx = slitherRules.findIndex( + (rule) => rule.id === 'color-assumption-inference', + ) + const sectorParityIdx = slitherRules.findIndex( + (rule) => rule.id === 'sector-parity-inference', + ) + const strongIdx = slitherRules.findIndex( + (rule) => rule.id === 'strong-inference', + ) + expect(colorAssumptionIdx).toBeGreaterThanOrEqual(0) + expect(sectorParityIdx).toBe(colorAssumptionIdx + 1) + expect(strongIdx).toBe(sectorParityIdx + 1) + }) + + it('is placed at the end of slitherRules', () => { + expect(slitherRules[slitherRules.length - 1]?.id).toBe('strong-inference') + }) + + it('uses direct color-edge contradiction to force the opposite color', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + const shared = edgeKey([0, 1], [1, 1]) + puzzle.edges[shared].mark = 'line' + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + ]) + expect(result?.message).toContain('contradiction') + expect(result?.message).toContain('color-edge contradiction') + expect(result?.message).toContain('edge V(0, 1)-V(1, 1)') + expect(result?.message).toContain('0 trial steps') + expect(result?.message).toContain('Searched 1 candidate') + expect(result?.message).toContain('is green') + expect(result?.inferenceDetails).toMatchObject({ + kind: 'slither-color-assumption', + defaultBranchId: 'green', + }) + expect(result?.inferenceDetails?.branches).toHaveLength(2) + }) + + it('compresses same-color candidate components before searching', () => { + const puzzle = createSlitherPuzzle(1, 4) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 3)] = { fill: 'yellow' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + ]) + expect(result?.message).toContain('from 2 candidate cells') + expect(result?.message).toContain('compressed to 1 component') + }) + + it('compresses opposite-color candidate components connected by a line edge', () => { + const puzzle = createSlitherPuzzle(1, 4) + puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } + puzzle.cells[cellKey(0, 3)] = { fill: 'yellow' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line' + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('from 2 candidate cells') + expect(result?.message).toContain('compressed to 1 component') + }) + + it('keeps the highest-scored representative within a compressed component', () => { + const puzzle = createSlitherPuzzle(1, 4) + puzzle.cells[cellKey(0, 0)] = { fill: 'yellow' } + puzzle.cells[cellKey(0, 3)] = { fill: 'yellow' } + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'blank' + puzzle.edges[edgeKey([0, 3], [1, 3])].mark = 'line' + puzzle.sectors[sectorKey(0, 2, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 2), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('from 2 candidate cells') + expect(result?.message).toContain('compressed to 1 component') + }) + + it('uses boundary color contradiction to force the opposite color', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 2], [1, 2])].mark = 'line' + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('is yellow') + }) + + it('uses deterministic downstream propagation to find a contradiction', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + const shared = edgeKey([0, 1], [1, 1]) + const downstreamRule: Rule = { + id: 'downstream-color-test', + name: 'Downstream Color Test', + apply: (trial) => { + if (trial.cells[cellKey(0, 1)]?.fill !== 'green') { + return null + } + if ((trial.edges[shared]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'test downstream edge consequence', + diffs: [ + { kind: 'edge', edgeKey: shared, from: 'unknown', to: 'line' }, + ], + affectedCells: [cellKey(0, 1)], + } + }, + } + const downstreamColorAssumptionRule = createColorAssumptionInferenceRule( + () => [downstreamRule], + ) + + const result = downstreamColorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(0, 1), + fromFill: null, + toFill: 'yellow', + }, + ]) + expect(result?.message).toContain('is green') + expect(result?.message).toContain('after 1 trial step') + expect(result?.message).toContain('green branch: 1 step') + }) + + it('infers from a quick contradiction even when the opposite branch only reaches the probe budget', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'blank' + const stallRule: Rule = { + id: 'stall-test', + name: 'Stall Test', + apply: (trial) => { + if (trial.cells[cellKey(0, 1)]?.fill !== 'green') { + return null + } + return { + message: 'keep this branch unresolved', + diffs: [ + { + kind: 'sector', + sectorKey: sectorKey(2, 2, 'se'), + fromMask: trial.sectors[sectorKey(2, 2, 'se')].constraintsMask, + toMask: trial.sectors[sectorKey(2, 2, 'se')].constraintsMask, + }, + ], + affectedCells: [], + affectedSectors: [sectorKey(2, 2, 'se')], + } + }, + } + const probingRule = createColorAssumptionInferenceRule(() => [stallRule], { + maxMs: Number.POSITIVE_INFINITY, + maxTrialSteps: 24, + }) + + const result = probingRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('probe budget 24') + expect(result?.message).toContain('green branch: unresolved after 24 steps') + expect(result?.message).toContain('yellow branch: 0 steps') + }) + + it('moves to later components when an earlier component is unresolved at the current probe budget', () => { + const puzzle = createSlitherPuzzle(3, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.sectors[sectorKey(0, 1, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.sectors[sectorKey(0, 1, 'ne')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.sectors[sectorKey(0, 1, 'sw')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.sectors[sectorKey(0, 1, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + const stallRule: Rule = { + id: 'stall-test', + name: 'Stall Test', + apply: () => ({ + message: 'keep this branch unresolved', + diffs: [ + { + kind: 'sector', + sectorKey: sectorKey(2, 2, 'se'), + fromMask: puzzle.sectors[sectorKey(2, 2, 'se')].constraintsMask, + toMask: puzzle.sectors[sectorKey(2, 2, 'se')].constraintsMask, + }, + ], + affectedCells: [], + affectedSectors: [sectorKey(2, 2, 'se')], + }), + } + const probingRule = createColorAssumptionInferenceRule(() => [stallRule], { + maxMs: Number.POSITIVE_INFINITY, + maxTrialSteps: 24, + }) + + const result = probingRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { + kind: 'cell', + cellKey: cellKey(1, 0), + fromFill: null, + toFill: 'yellow', + }, + ]) + expect(result?.message).toContain('Searched 2 candidate components') + expect(result?.message).toContain('probe budget 24') + }) + + it('treats unreachable fixed green regions as a contradiction', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 2)] = { fill: 'green' } + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'cell', cellKey: cellKey(0, 1), fromFill: null, toFill: 'green' }, + ]) + expect(result?.message).toContain('is yellow') + expect(result?.message).toContain('disconnected-green contradiction') + expect(result?.message).toContain('yellow branch: 0 steps') + }) + + it('returns null when both color branches remain feasible', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + + const result = colorAssumptionRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('uses contradiction on onlyOne sector branches to force opposite assignment', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.sectors[sectorKey(0, 0, 'se')].constraintsMask = SECTOR_MASK_ONLY_1 + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [2, 0])].mark = 'blank' + + const result = strongRule.apply(puzzle) + + expect(result).not.toBeNull() + const bottom = edgeKey([1, 0], [1, 1]) + const right = edgeKey([0, 1], [1, 1]) + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + ]) + expect(result?.affectedSectors).toEqual([sectorKey(0, 0, 'se')]) + expect(result?.message).toContain('must have exactly one line') + expect(result?.message).toContain('contradicts the puzzle') + }) + + it('returns null when both onlyOne branches remain feasible', () => { + const puzzle = createSlitherPuzzle(1, 1) + puzzle.sectors[sectorKey(0, 0, 'nw')].constraintsMask = SECTOR_MASK_ONLY_1 + + const result = strongRule.apply(puzzle) + + expect(result).toBeNull() + }) + + it('supports vertex-two-choice contradiction on a boundary vertex', () => { + const directStrongRule = createStrongInferenceRule(() => []) + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 1) + + const up = edgeKey([0, 0], [1, 0]) + const down = edgeKey([1, 0], [2, 0]) + const right = edgeKey([1, 0], [1, 1]) + puzzle.edges[right].mark = 'line' + + const result = directStrongRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: up, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: down, from: 'unknown', to: 'line' }, + ]) + expect(result?.message).toContain('has two possible continuations') + expect(result?.message).toContain('contradicts the puzzle') + expect(result?.inferenceDetails?.kind).toBe('slither-strong') + expect(result?.inferenceDetails?.branches).toHaveLength(2) + }) + + it('forces an edge branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { + const puzzle = createSlitherPuzzle(1, 1) + setClue(puzzle, 0, 0, 1) + const top = edgeKey([0, 0], [0, 1]) + const bottom = edgeKey([1, 0], [1, 1]) + const left = edgeKey([0, 0], [1, 0]) + const right = edgeKey([0, 1], [1, 1]) + const stallRule: Rule = { + id: 'strong-edge-stall-test', + name: 'Strong Edge Stall Test', + apply: (trial) => { + const topMark = trial.edges[top]?.mark ?? 'unknown' + if (topMark === 'blank') { + return { + message: 'make blank branch contradict the clue', + diffs: [ + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + ], + affectedCells: [cellKey(0, 0)], + } + } + if (topMark !== 'line') { + return null + } + return { + message: 'keep line branch unresolved', + diffs: [ + { + kind: 'sector', + sectorKey: sectorKey(0, 0, 'nw'), + fromMask: trial.sectors[sectorKey(0, 0, 'nw')].constraintsMask, + toMask: trial.sectors[sectorKey(0, 0, 'nw')].constraintsMask, + }, + ], + affectedCells: [cellKey(0, 0)], + affectedSectors: [sectorKey(0, 0, 'nw')], + } + }, + } + const probingStrongRule = createStrongInferenceRule(() => [stallRule], { + maxMs: Number.POSITIVE_INFINITY, + maxTrialSteps: 24, + }) + + const result = probingStrongRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, + ]) + expect(result?.message).toContain('probe budget 24') + expect(result?.message).toContain('A branch unresolved after 24 steps') + expect(result?.message).toContain('B branch contradicted after 1 step') + }) + + it('forces a binary strong branch when the opposite branch contradicts and the survivor reaches the probe budget', () => { + const puzzle = createSlitherPuzzle(2, 2) + setClue(puzzle, 0, 0, 1) + + const up = edgeKey([0, 0], [1, 0]) + const down = edgeKey([1, 0], [2, 0]) + const right = edgeKey([1, 0], [1, 1]) + puzzle.edges[right].mark = 'line' + const stallRule: Rule = { + id: 'strong-binary-stall-test', + name: 'Strong Binary Stall Test', + apply: (trial) => { + if ((trial.edges[up]?.mark ?? 'unknown') !== 'blank') { + return null + } + if ((trial.edges[down]?.mark ?? 'unknown') !== 'line') { + return null + } + return { + message: 'keep surviving binary branch unresolved', + diffs: [ + { + kind: 'sector', + sectorKey: sectorKey(1, 0, 'nw'), + fromMask: trial.sectors[sectorKey(1, 0, 'nw')].constraintsMask, + toMask: trial.sectors[sectorKey(1, 0, 'nw')].constraintsMask, + }, + ], + affectedCells: [cellKey(1, 0)], + affectedSectors: [sectorKey(1, 0, 'nw')], + } + }, + } + const probingStrongRule = createStrongInferenceRule(() => [stallRule], { + maxMs: Number.POSITIVE_INFINITY, + maxTrialSteps: 24, + }) + + const result = probingStrongRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: up, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: down, from: 'unknown', to: 'line' }, + ]) + expect(result?.message).toContain('has two possible continuations') + expect(result?.message).toContain('probe budget 24') + expect(result?.message).toContain('A branch contradicted after 0 steps') + expect(result?.message).toContain('B branch unresolved after 24 steps') + }) + + it('extracts shared consequences when both feasible branches agree downstream', () => { + const puzzle = createSlitherPuzzle(2, 2) + const sector = sectorKey(0, 0, 'se') + puzzle.sectors[sector].constraintsMask = SECTOR_MASK_ONLY_1 + + const [bottom, right] = getCornerEdgeKeys(0, 0, 'se') + const top = edgeKey([0, 0], [0, 1]) + const sharedConsequenceRule: Rule = { + id: 'shared-consequence-test', + name: 'Shared Consequence Test', + apply: (trial) => { + if ((trial.edges[top]?.mark ?? 'unknown') !== 'unknown') { + return null + } + const bottomMark = trial.edges[bottom]?.mark ?? 'unknown' + const rightMark = trial.edges[right]?.mark ?? 'unknown' + if (bottomMark !== 'line' && rightMark !== 'line') { + return null + } + return { + message: 'test shared consequence', + diffs: [{ kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }], + affectedCells: [cellKey(0, 0)], + } + }, + } + const sharedStrongRule = createStrongInferenceRule(() => [ + sharedConsequenceRule, + ]) + + const result = sharedStrongRule.apply(puzzle) + + expect(result).not.toBeNull() + expect(result?.diffs).toEqual([ + { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, + ]) + expect(result?.message).toContain('must have exactly one line') + expect(result?.message).toContain('same consequence') + expect(result?.message).toContain('probe budget') + }) + + it('can run on the provided 10x10 puzzle after deterministic stabilization', () => { + const rulesWithoutStrong = slitherRules.filter( + (rule) => rule.id !== 'strong-inference', + ) + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/10/10/c3ch08c0d22aodh1bgdbjbag3dhdo12c3a52ah3b0', + ) + + for (let stepNumber = 1; stepNumber <= 400; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + rulesWithoutStrong, + stepNumber, + ) + if (!step) { + break + } + current = nextPuzzle + } + + expect(() => unboundedStrongRule.apply(current)).not.toThrow() + const result = unboundedStrongRule.apply(current) + expect(result === null || result.diffs.length > 0).toBe(true) + }) + + it('lets deterministic cut coloring advance the provided 18x10 stuck puzzle', () => { + const rulesBeforeColorAssumption = slitherRules.filter( + (rule) => + rule.id !== 'color-assumption-inference' && + rule.id !== 'sector-parity-inference' && + rule.id !== 'strong-inference', + ) + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/18/10/l12cg261b353didb1bbg112dgb2bbci161b3dgbhapchcg3c161dicb2bbg111cga2bbbi271c161bg31cj', + ) + + for (let stepNumber = 1; stepNumber <= 2000; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + rulesBeforeColorAssumption, + stepNumber, + ) + if (!step) { + break + } + current = nextPuzzle + } + + const result = unboundedColorAssumptionRule.apply(current) + expect(result).toBeNull() + expect(current.cells[cellKey(2, 12)]?.fill).toBe('green') + expect(current.cells[cellKey(7, 0)]?.fill).toBe('green') + + const targetBranch = clonePuzzle(current) + targetBranch.cells[cellKey(7, 0)] = { + ...(targetBranch.cells[cellKey(7, 0)] ?? {}), + fill: 'yellow', + } + const targetResult = runTrialUntilFixpoint( + targetBranch, + rulesBeforeColorAssumption, + 120, + Number.POSITIVE_INFINITY, + ) + expect(targetResult.contradiction).toBe(true) + }) + + it('keeps the provided 6x100 target edge covered after deterministic stabilization', () => { + const rulesWithoutStrong = slitherRules.filter( + (rule) => rule.id !== 'strong-inference', + ) + const target = edgeKey([23, 0], [24, 0]) + let current = decodeSlitherFromPuzzlink( + 'https://puzz.link/p?slither/6/100/h1dgdabdg3bgdddbg2cgcddag0bgdcbag0bgdbcdg1cgbdddg1bgbdddg2dgaadbg1cgaddbg0bgdbacg1bgadccg3cgaacdg2cgbbadg3agbbbag3cgdcddg2bgcddag2bgaabdg2bgdbdag3bgcdbcg2cgdddbg2cgdddag2bgddcag2bgcdaag3bgdddcg2cgcaddg2bgabddg1bgdadcg3bgbdcdg1bgddddg1dgdbbdg3agbbdag1dgbdddg2agadddg1d', + ) + + for (let stepNumber = 1; stepNumber <= 2000; stepNumber += 1) { + const { nextPuzzle, step } = runNextRule( + current, + rulesWithoutStrong, + stepNumber, + ) + if (!step) { + break + } + current = nextPuzzle + } + + expect(current.edges[target]?.mark ?? 'unknown').toBe('blank') + }) +}) diff --git a/src/domain/rules/slither/tests/testUtils.ts b/src/domain/rules/slither/tests/testUtils.ts new file mode 100644 index 0000000..c445e1d --- /dev/null +++ b/src/domain/rules/slither/tests/testUtils.ts @@ -0,0 +1,21 @@ +import { cellKey } from '../../../ir/keys' +import type { PuzzleIR } from '../../../ir/types' +import { slitherRules } from '../rules' + +export const setClue = ( + puzzle: PuzzleIR, + row: number, + col: number, + value: number, +): void => { + puzzle.cells[cellKey(row, col)] = { + clue: { kind: 'number', value }, + } +} + +export const getEdgeDiffKeys = ( + result: ReturnType<(typeof slitherRules)[number]['apply']>, +): string[] => + result?.diffs.flatMap((diff) => + diff.kind === 'edge' ? [diff.edgeKey] : [], + ) ?? [] diff --git a/src/domain/rules/slither/tests/trial.test.ts b/src/domain/rules/slither/tests/trial.test.ts new file mode 100644 index 0000000..ead8eb9 --- /dev/null +++ b/src/domain/rules/slither/tests/trial.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { cellKey, edgeKey, sectorKey, vertexKey } from '../../../ir/keys' +import { createSlitherPuzzle } from '../../../ir/slither' +import type { Rule } from '../../types' +import { + findHardContradictionReason, + runTrialUntilFixpoint, +} from '../rules/trial' +import { setClue } from './testUtils' + +describe('slither trial diagnostics', () => { + it('reports vertex-degree contradiction locations', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 1], [1, 2])].mark = 'line' + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('vertex-degree') + expect(reason?.message).toContain('V(1, 1)') + expect(reason?.vertices).toEqual([vertexKey(1, 1)]) + }) + + it('reports cell-clue contradiction locations', () => { + const puzzle = createSlitherPuzzle(1, 1) + setClue(puzzle, 0, 0, 0) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('cell-clue') + expect(reason?.message).toContain('(R1, C1)') + expect(reason?.cells).toEqual([cellKey(0, 0)]) + }) + + it('reports sector-mask contradiction locations', () => { + const puzzle = createSlitherPuzzle(1, 1) + const targetSector = sectorKey(0, 0, 'nw') + puzzle.sectors[targetSector].constraintsMask = 0 + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('sector-mask') + expect(reason?.message).toContain('(R1, C1, NW)') + expect(reason?.sectors).toEqual([targetSector]) + }) + + it('reports vertex-candidates contradiction locations', () => { + const puzzle = createSlitherPuzzle(1, 1) + puzzle.vertices[vertexKey(0, 0)].candidateEdgeSets = [] + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('vertex-candidates') + expect(reason?.message).toContain('V(0, 0)') + }) + + it('reports color-edge contradiction locations', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + const shared = edgeKey([0, 1], [1, 1]) + puzzle.edges[shared].mark = 'line' + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('color-edge') + expect(reason?.message).toContain('edge V(0, 1)-V(1, 1)') + expect(reason?.edges).toEqual([shared]) + }) + + it('reports line-loop contradiction shape', () => { + const puzzle = createSlitherPuzzle(2, 2) + puzzle.edges[edgeKey([0, 0], [0, 1])].mark = 'line' + puzzle.edges[edgeKey([0, 0], [1, 0])].mark = 'line' + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([1, 0], [1, 1])].mark = 'line' + puzzle.edges[edgeKey([2, 1], [2, 2])].mark = 'line' + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('line-loop') + expect(reason?.message).toContain('closed loop') + }) + + it('reports disconnected-green contradiction locations', () => { + const puzzle = createSlitherPuzzle(1, 3) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 1)] = { fill: 'yellow' } + puzzle.cells[cellKey(0, 2)] = { fill: 'green' } + + const reason = findHardContradictionReason(puzzle) + + expect(reason?.kind).toBe('disconnected-green') + expect(reason?.message).toContain('(R1, C3)') + }) + + it('reports zero trial steps for immediate contradictions', () => { + const puzzle = createSlitherPuzzle(1, 2) + puzzle.cells[cellKey(0, 0)] = { fill: 'green' } + puzzle.cells[cellKey(0, 1)] = { fill: 'green' } + puzzle.edges[edgeKey([0, 1], [1, 1])].mark = 'line' + + const result = runTrialUntilFixpoint( + puzzle, + [], + 10, + Number.POSITIVE_INFINITY, + ) + + expect(result.contradiction).toBe(true) + expect(result.stepsRun).toBe(0) + expect(result.contradictionReason?.kind).toBe('color-edge') + }) + + it('keeps exhausted trial behavior while reporting steps run', () => { + const puzzle = createSlitherPuzzle(1, 2) + const target = edgeKey([0, 0], [0, 1]) + const oneStepRule: Rule = { + id: 'trial-diagnostic-one-step', + name: 'Trial Diagnostic One Step', + apply: (trial) => { + if ((trial.edges[target]?.mark ?? 'unknown') !== 'unknown') { + return null + } + return { + message: 'test one trial step', + diffs: [ + { kind: 'edge', edgeKey: target, from: 'unknown', to: 'blank' }, + ], + affectedCells: [], + } + }, + } + + const result = runTrialUntilFixpoint( + puzzle, + [oneStepRule], + 1, + Number.POSITIVE_INFINITY, + ) + + expect(result.contradiction).toBe(false) + expect(result.exhausted).toBe(true) + expect(result.stepsRun).toBe(1) + }) +}) diff --git a/src/domain/rules/types.ts b/src/domain/rules/types.ts index cb77346..2df6ea6 100644 --- a/src/domain/rules/types.ts +++ b/src/domain/rules/types.ts @@ -56,6 +56,50 @@ export type RuleDiff = | TileDiff | VertexDiff +export type InferenceFocus = { + cells?: string[] + edges?: string[] + lines?: string[] + tiles?: string[] + sectors?: string[] + vertices?: string[] +} + +export type InferenceContradiction = InferenceFocus & { + kind: string + message: string +} + +export type TrialTraceStep = { + ruleId: string + ruleName: string + message: string + diffs: RuleDiff[] + affectedCells: string[] + affectedEdges: string[] + affectedLines?: string[] + affectedTiles?: string[] + affectedSectors: string[] +} + +export type InferenceBranch = { + id: string + label: string + role?: 'trial' | 'forced-conclusion' + initialDiffs: RuleDiff[] + status: 'contradiction' | 'unresolved' | 'exhausted' | 'forced' + traceSteps: TrialTraceStep[] + contradiction?: InferenceContradiction +} + +export type InferenceDetails = { + kind: 'slither-strong' | 'slither-color-assumption' | 'slither-sector-parity' | 'masyu-strong' + conclusion: 'opposite-branch' | 'shared-consequence' + basePuzzle: PuzzleIR + defaultBranchId: string + branches: InferenceBranch[] +} + export type RuleStep = { id: string ruleId: string @@ -72,6 +116,7 @@ export type RuleStep = { chainDurationMs?: number ruleApplyMs?: number ruleAttempts?: RuleAttempt[] + inferenceDetails?: InferenceDetails } export type RuleApplication = { @@ -81,6 +126,7 @@ export type RuleApplication = { affectedTiles?: string[] affectedLines?: string[] affectedSectors?: string[] + inferenceDetails?: InferenceDetails } export type RuleAttempt = { diff --git a/src/features/board/CanvasBoard.tsx b/src/features/board/CanvasBoard.tsx index c93bf36..02af5fb 100644 --- a/src/features/board/CanvasBoard.tsx +++ b/src/features/board/CanvasBoard.tsx @@ -6,6 +6,7 @@ import { parseLineKey, parseSectorKey, parseTileKey, + parseVertexKey, } from '../../domain/ir/keys' import { SECTOR_MASK_ALL, @@ -14,6 +15,7 @@ import { type SectorCorner, } from '../../domain/ir/types' import type { PuzzleIR } from '../../domain/ir/types' +import type { InferenceFocus, RuleDiff } from '../../domain/rules/types' import { PuzzleStatsInfoButton } from '../puzzleStats/PuzzleStatsInfoButton' import type { DisplaySettings } from '../solver/solverStore' import { BoardDisplayButton } from './BoardDisplayButton' @@ -28,6 +30,11 @@ type Props = { highlightedColorTiles?: string[] displaySettings: DisplaySettings onSetDisplayOption: (optionId: string, enabled: boolean) => void + variant?: 'panel' | 'surface' + inferenceDiffs?: RuleDiff[] + inferenceDiffRole?: 'assumption' | 'conclusion' + contradictionFocus?: InferenceFocus + ariaLabel?: string } const CELL_SIZE = 52 @@ -106,6 +113,11 @@ export const CanvasBoard = ({ highlightedColorTiles = [], displaySettings, onSetDisplayOption, + variant = 'panel', + inferenceDiffs = [], + inferenceDiffRole = 'assumption', + contradictionFocus, + ariaLabel, }: Props) => { const canvasRef = useRef(null) const [zoomPercent, setZoomPercent] = useState(100) @@ -398,6 +410,107 @@ export const CanvasBoard = ({ } } + if (inferenceDiffs.length > 0) { + ctx.save() + ctx.strokeStyle = + inferenceDiffRole === 'conclusion' ? '#7c3aed' : '#f97316' + ctx.fillStyle = + inferenceDiffRole === 'conclusion' + ? 'rgba(124, 58, 237, 0.16)' + : 'rgba(249, 115, 22, 0.16)' + ctx.lineWidth = 4 + ctx.setLineDash([8, 5]) + for (const diff of inferenceDiffs) { + if (diff.kind === 'edge') { + const [v1, v2] = parseEdgeKey(diff.edgeKey) + ctx.beginPath() + ctx.moveTo(PADDING + v1[1] * CELL_SIZE, PADDING + v1[0] * CELL_SIZE) + ctx.lineTo(PADDING + v2[1] * CELL_SIZE, PADDING + v2[0] * CELL_SIZE) + ctx.stroke() + } else if (diff.kind === 'line') { + const [v1, v2] = parseLineKey(diff.lineKey) + ctx.beginPath() + ctx.moveTo( + PADDING + v1[1] * CELL_SIZE + CELL_SIZE / 2, + PADDING + v1[0] * CELL_SIZE + CELL_SIZE / 2, + ) + ctx.lineTo( + PADDING + v2[1] * CELL_SIZE + CELL_SIZE / 2, + PADDING + v2[0] * CELL_SIZE + CELL_SIZE / 2, + ) + ctx.stroke() + } else if (diff.kind === 'cell') { + const [r, c] = parseCellKey(diff.cellKey) + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + ctx.strokeRect(PADDING + c * CELL_SIZE + 3, PADDING + r * CELL_SIZE + 3, CELL_SIZE - 6, CELL_SIZE - 6) + } else if (diff.kind === 'tile') { + const [r, c] = parseTileKey(diff.tileKey) + ctx.beginPath() + ctx.arc(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, 10, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + } + ctx.restore() + } + + if (contradictionFocus) { + ctx.save() + ctx.strokeStyle = '#dc2626' + ctx.fillStyle = 'rgba(220, 38, 38, 0.2)' + ctx.lineWidth = 5 + ctx.setLineDash([]) + for (const key of contradictionFocus.cells ?? []) { + const [r, c] = parseCellKey(key) + ctx.fillRect(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, CELL_SIZE, CELL_SIZE) + ctx.strokeRect(PADDING + c * CELL_SIZE + 2, PADDING + r * CELL_SIZE + 2, CELL_SIZE - 4, CELL_SIZE - 4) + } + for (const key of contradictionFocus.edges ?? []) { + const [v1, v2] = parseEdgeKey(key) + ctx.beginPath() + ctx.moveTo(PADDING + v1[1] * CELL_SIZE, PADDING + v1[0] * CELL_SIZE) + ctx.lineTo(PADDING + v2[1] * CELL_SIZE, PADDING + v2[0] * CELL_SIZE) + ctx.stroke() + } + for (const key of contradictionFocus.lines ?? []) { + const [v1, v2] = parseLineKey(key) + ctx.beginPath() + ctx.moveTo( + PADDING + v1[1] * CELL_SIZE + CELL_SIZE / 2, + PADDING + v1[0] * CELL_SIZE + CELL_SIZE / 2, + ) + ctx.lineTo( + PADDING + v2[1] * CELL_SIZE + CELL_SIZE / 2, + PADDING + v2[0] * CELL_SIZE + CELL_SIZE / 2, + ) + ctx.stroke() + } + for (const key of contradictionFocus.tiles ?? []) { + const [r, c] = parseTileKey(key) + ctx.beginPath() + ctx.arc(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, 12, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + for (const key of contradictionFocus.vertices ?? []) { + const [r, c] = parseVertexKey(key) + ctx.beginPath() + ctx.arc(PADDING + c * CELL_SIZE, PADDING + r * CELL_SIZE, 9, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + for (const key of contradictionFocus.sectors ?? []) { + const [r, c, corner] = parseSectorKey(key) + const x = PADDING + (c + (corner === 'ne' || corner === 'se' ? 1 : 0)) * CELL_SIZE + const y = PADDING + (r + (corner === 'sw' || corner === 'se' ? 1 : 0)) * CELL_SIZE + ctx.beginPath() + ctx.arc(x, y, 13, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + ctx.restore() + } + ctx.restore() }, [ displayHeight, @@ -410,6 +523,9 @@ export const CanvasBoard = ({ highlightedLines, displaySettings, puzzle, + inferenceDiffs, + inferenceDiffRole, + contradictionFocus, width, zoom, ]) @@ -427,6 +543,40 @@ export const CanvasBoard = ({ return { lineCount, blankCount, unknownCount } }, [puzzle.edges, puzzle.lines, puzzle.puzzleType]) + const canvasSurface = ( +
+ +
+ ) + + if (variant === 'surface') { + return ( +
+
+ +
+ {canvasSurface} +
+ ) + } + return (
@@ -461,14 +611,7 @@ export const CanvasBoard = ({
-
- -
+ {canvasSurface}

Use the slider to zoom. Scroll to move around large grids. Highlight syncs with reasoning steps. diff --git a/src/features/dataset/publicDatasets.test.ts b/src/features/dataset/publicDatasets.test.ts new file mode 100644 index 0000000..608ef59 --- /dev/null +++ b/src/features/dataset/publicDatasets.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { validateBenchmarkManifest } from '../../domain/benchmark' +import { puzzleRegistry } from '../../domain/plugins/registry' +import { publicDatasetManifests } from './publicDatasets' + +describe('public datasets', () => { + it('provides a valid, categorized, and parseable Masyu manifest', () => { + const manifest = publicDatasetManifests.find( + (candidate) => candidate.id === 'masyu', + ) + + expect(manifest).toBeDefined() + const validated = validateBenchmarkManifest(manifest) + expect(validated.puzzleType).toBe('masyu') + expect(validated.title).toBe('Masyu Dataset') + expect(validated.items).toHaveLength(55) + expect(new Set(validated.items.map((item) => item.id)).size).toBe(55) + + const existingItems = validated.items.slice(0, 37) + const newItems = validated.items.slice(37) + expect(existingItems.every((item) => item.tags.includes('hard'))).toBe(true) + expect(newItems.every((item) => item.tags.includes('easy'))).toBe(true) + expect(newItems.every((item) => item.source === 'example.txt')).toBe(true) + expect(newItems.map((item) => item.id)).toEqual([ + 'masyu-15x21-0005', + 'masyu-10x10-0006', + 'masyu-10x10-0007', + 'masyu-10x10-0008', + 'masyu-10x10-0009', + 'masyu-10x10-0010', + 'masyu-25x40-0011', + 'masyu-25x40-0012', + 'masyu-39x49-0013', + 'masyu-10x18-0014', + 'masyu-12x14-0015', + 'masyu-10x10-0016', + 'masyu-10x10-0017', + 'masyu-20x29-0018', + 'masyu-13x18-0019', + 'masyu-40x40-0020', + 'masyu-30x30-0021', + 'masyu-15x10-0022', + ]) + + const plugin = puzzleRegistry.get('masyu') + expect(plugin).toBeDefined() + for (const item of validated.items) { + const area = item.width * item.height + const sizeTag = area < 150 ? 'Small' : area < 500 ? 'Medium' : 'Large' + const difficultyTag = item.tags.includes('hard') ? 'hard' : 'easy' + + expect(item.puzzleType).toBe('masyu') + expect(item.tags).toEqual([difficultyTag, sizeTag]) + expect(item.id).toMatch(/^masyu-\d+x\d+-\d+$/) + if (difficultyTag === 'hard') { + expect(item.source).toBe( + item.width === 25 + ? 'https://zh.puzzle-masyu.com/?size=18' + : 'https://zh.puzzle-masyu.com/?size=12', + ) + } + + const puzzle = plugin!.parse(item.sourceUrl) + expect(puzzle.puzzleType).toBe('masyu') + expect(puzzle.cols).toBe(item.width) + expect(puzzle.rows).toBe(item.height) + } + }) +}) diff --git a/src/features/dataset/publicDatasets.ts b/src/features/dataset/publicDatasets.ts index d6238c7..42525a0 100644 --- a/src/features/dataset/publicDatasets.ts +++ b/src/features/dataset/publicDatasets.ts @@ -1,8 +1,11 @@ import type { BenchmarkDatasetManifest } from '../../domain/benchmark/types' +import masyuRaw from '../../../dataset/public/masyu.json?raw' import slitherlinkExampleRaw from '../../../dataset/public/slitherlink.example.json?raw' -const parseManifest = (raw: string): BenchmarkDatasetManifest => JSON.parse(raw) as BenchmarkDatasetManifest +const parseManifest = (raw: string): BenchmarkDatasetManifest => + JSON.parse(raw) as BenchmarkDatasetManifest export const publicDatasetManifests: BenchmarkDatasetManifest[] = [ parseManifest(slitherlinkExampleRaw), + parseManifest(masyuRaw), ] diff --git a/src/features/docs/DocsIndex.tsx b/src/features/docs/DocsIndex.tsx new file mode 100644 index 0000000..d6e74a3 --- /dev/null +++ b/src/features/docs/DocsIndex.tsx @@ -0,0 +1,87 @@ +import { Link } from 'react-router-dom' +import MasyuOverview from '../../../docs/content/masyu/overview.mdx' +import SlitherlinkOverview from '../../../docs/content/slitherlink/overview.mdx' +import { puzzleRegistry } from '../../domain/plugins/registry' +import { getRuleDocPath, ruleDocEntries } from './ruleDocRegistry' + +const familyOverviews = { + masyu: MasyuOverview, + slitherlink: SlitherlinkOverview, +} + +export const DocsIndex = ({ puzzleId }: { puzzleId?: string }) => { + const plugins = puzzleRegistry + .all() + .filter((plugin) => plugin.getRules().length > 0) + .filter((plugin) => !puzzleId || plugin.id === puzzleId) + + return ( +

+
+

+ {puzzleId + ? `${puzzleRegistry.get(puzzleId)?.displayName ?? puzzleId} Documentation` + : 'Rule Documentation'} +

+

+ Puzzle rules describe the goal. Solver techniques explain each + deduction that PuzzleKit can make and replay. +

+ {puzzleId && puzzleId in familyOverviews ? ( +
+ {(() => { + const Overview = + familyOverviews[puzzleId as keyof typeof familyOverviews] + return + })()} +
+ ) : null} +
+ {plugins.map((plugin) => { + const entries = ruleDocEntries.filter( + (entry) => entry.puzzleId === plugin.id, + ) + const categories = Array.from( + new Set(entries.map((entry) => entry.category)), + ) + return ( +
+
+
+

{plugin.displayName}

+

+ { + entries.filter((entry) => entry.status === 'documented') + .length + }{' '} + documented / {entries.length} registered techniques +

+
+ {!puzzleId ? ( + Open family + ) : null} +
+ {categories.map((category) => ( +
+

{category}

+
+ {entries + .filter((entry) => entry.category === category) + .map((entry) => ( + + {entry.title} + {entry.status} + + ))} +
+
+ ))} +
+ ) + })} +
+ ) +} diff --git a/src/features/docs/DraftRuleContent.tsx b/src/features/docs/DraftRuleContent.tsx new file mode 100644 index 0000000..0783cd8 --- /dev/null +++ b/src/features/docs/DraftRuleContent.tsx @@ -0,0 +1,9 @@ +export const DraftRuleContent = () => ( + <> +

Documentation in progress

+

+ This solver technique is registered and linkable, but its detailed + explanation and example have not been written yet. +

+ +) diff --git a/src/features/docs/RuleDocPage.tsx b/src/features/docs/RuleDocPage.tsx new file mode 100644 index 0000000..824c67e --- /dev/null +++ b/src/features/docs/RuleDocPage.tsx @@ -0,0 +1,36 @@ +import { Link } from 'react-router-dom' +import { puzzleRegistry } from '../../domain/plugins/registry' +import { RuleExample } from './RuleExample' +import type { RuleDocEntry } from './ruleDocRegistry' + +export const RuleDocPage = ({ entry }: { entry: RuleDocEntry }) => { + const plugin = puzzleRegistry.get(entry.puzzleId) + const Content = entry.content + + return ( +
+ +
+ + {entry.status} + +

{entry.category}

+

{entry.title}

+

{entry.summary}

+ {entry.ruleId} +
+
+ +
+ {entry.example ? : null} +
+ ) +} diff --git a/src/features/docs/RuleExample.tsx b/src/features/docs/RuleExample.tsx new file mode 100644 index 0000000..d9297ad --- /dev/null +++ b/src/features/docs/RuleExample.tsx @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from 'react' +import type { PuzzleIR } from '../../domain/ir/types' +import { applyRuleDiffs } from '../../domain/rules/engine' +import type { InferenceFocus, RuleDiff } from '../../domain/rules/types' +import { CanvasBoard } from '../board/CanvasBoard' +import type { DisplaySettings } from '../solver/solverStore' + +type Props = { + puzzle: PuzzleIR + before?: RuleDiff[] + after: RuleDiff[] + highlights?: InferenceFocus + explanation?: string +} + +const displaySettings: DisplaySettings = { + showCoordinates: false, + showCellColors: true, + showEdgeCrosses: true, + showSectorMarks: true, + showVertices: true, + showTiles: true, + showLineCrosses: true, + showHighlights: true, + showGridLabels: false, + showGrid: true, +} + +export const RuleExample = ({ + puzzle, + before = [], + after, + highlights = {}, + explanation, +}: Props) => { + const [view, setView] = useState<'before' | 'after'>('before') + const [isPlaying, setIsPlaying] = useState(false) + const beforePuzzle = useMemo( + () => applyRuleDiffs(puzzle, before), + [before, puzzle], + ) + const afterPuzzle = useMemo( + () => applyRuleDiffs(beforePuzzle, after), + [after, beforePuzzle], + ) + + useEffect(() => { + if (!isPlaying) { + return + } + const timer = window.setTimeout(() => { + setView('after') + setIsPlaying(false) + }, 700) + return () => window.clearTimeout(timer) + }, [isPlaying]) + + return ( +
+
+
+ + +
+ +
+ undefined} + variant="surface" + inferenceDiffs={view === 'after' ? after : []} + inferenceDiffRole="conclusion" + ariaLabel="Rule documentation example" + /> + {explanation ?
{explanation}
: null} +
+ ) +} diff --git a/src/features/docs/ruleDocRegistry.test.ts b/src/features/docs/ruleDocRegistry.test.ts new file mode 100644 index 0000000..2db2eba --- /dev/null +++ b/src/features/docs/ruleDocRegistry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { puzzleRegistry } from '../../domain/plugins/registry' +import { + getRuleDocEntry, + getRuleDocPath, + ruleDocEntries, +} from './ruleDocRegistry' + +describe('ruleDocRegistry', () => { + it('registers every production rule exactly once', () => { + const productionRules = puzzleRegistry + .all() + .flatMap((plugin) => + plugin.getRules().map((rule) => `${plugin.id}:${rule.id}`), + ) + const documentedRules = ruleDocEntries.map( + (entry) => `${entry.puzzleId}:${entry.ruleId}`, + ) + + expect(new Set(documentedRules).size).toBe(documentedRules.length) + expect(documentedRules.sort()).toEqual(productionRules.sort()) + }) + + it('resolves stable rule paths by puzzle id and rule id', () => { + expect(getRuleDocPath('masyu', 'white-pearl-rule')).toBe( + '/docs/masyu/rules/white-pearl-rule', + ) + expect(getRuleDocEntry('masyu', 'white-pearl-rule')?.status).toBe( + 'documented', + ) + }) +}) diff --git a/src/features/docs/ruleDocRegistry.tsx b/src/features/docs/ruleDocRegistry.tsx new file mode 100644 index 0000000..d00ece6 --- /dev/null +++ b/src/features/docs/ruleDocRegistry.tsx @@ -0,0 +1,105 @@ +import type { ComponentType } from 'react' +import BlackPearlRuleDoc from '../../../docs/content/masyu/rules/black-pearl-rule.mdx' +import WhitePearlRuleDoc from '../../../docs/content/masyu/rules/white-pearl-rule.mdx' +import VertexDegreeRuleDoc from '../../../docs/content/slitherlink/rules/vertex-degree.mdx' +import { puzzleRegistry } from '../../domain/plugins/registry' +import type { Rule } from '../../domain/rules/types' +import { DraftRuleContent } from './DraftRuleContent' +import { ruleExamples, type RuleExampleData } from './ruleExamples' + +export type RuleDocStatus = 'documented' | 'draft' | 'missing' + +export type RuleDocEntry = { + puzzleId: string + ruleId: string + title: string + summary: string + category: string + content: ComponentType + status: RuleDocStatus + example?: RuleExampleData +} + +const documentedContent: Record< + string, + { content: ComponentType; summary: string } +> = { + 'masyu:white-pearl-rule': { + content: WhitePearlRuleDoc, + summary: + 'Keeps a white pearl straight while preserving its required adjacent turn.', + }, + 'masyu:black-pearl-rule': { + content: BlackPearlRuleDoc, + summary: + 'Forces a turn on a black pearl and straight continuation after each exit.', + }, + 'slitherlink:vertex-degree': { + content: VertexDegreeRuleDoc, + summary: + 'Maintains the loop degree of zero or two at every Slitherlink vertex.', + }, +} + +const getCategory = (rule: Rule): string => { + if ( + rule.id.includes('strong') || + rule.id.includes('assumption') || + rule.id.includes('parity-inference') + ) { + return 'Bounded Inference' + } + if (rule.id.includes('color') || rule.id.includes('tile')) { + return 'Color and Region Reasoning' + } + if (rule.id.includes('sector') || rule.id.includes('vertex')) { + return 'Sector and Vertex Reasoning' + } + if ( + rule.id.includes('pearl') || + rule.id.includes('white') || + rule.id.includes('black') + ) { + return 'Pearl Techniques' + } + if ( + rule.id.includes('loop') || + rule.id.includes('bridge') || + rule.id.includes('connectivity') + ) { + return 'Loop and Connectivity' + } + return 'Core Techniques' +} + +export const getRuleDocPath = (puzzleId: string, ruleId: string): string => + `/docs/${encodeURIComponent(puzzleId)}/rules/${encodeURIComponent(ruleId)}` + +export const ruleDocEntries: RuleDocEntry[] = puzzleRegistry + .all() + .flatMap((plugin) => + plugin.getRules().map((rule) => { + const key = `${plugin.id}:${rule.id}` + const documented = documentedContent[key] + return { + puzzleId: plugin.id, + ruleId: rule.id, + title: rule.name, + summary: + documented?.summary ?? + 'A registered PuzzleKit solver technique. Detailed documentation is being prepared.', + category: getCategory(rule), + content: documented?.content ?? DraftRuleContent, + status: documented ? 'documented' : 'draft', + example: ruleExamples[key], + } satisfies RuleDocEntry + }), + ) + +export const getRuleDocEntry = ( + puzzleId: string, + ruleId: string, +): RuleDocEntry | undefined => + ruleDocEntries.find( + (entry) => entry.puzzleId === puzzleId && entry.ruleId === ruleId, + ) diff --git a/src/features/docs/ruleExamples.ts b/src/features/docs/ruleExamples.ts new file mode 100644 index 0000000..ee5dddc --- /dev/null +++ b/src/features/docs/ruleExamples.ts @@ -0,0 +1,81 @@ +import { cellKey, edgeKey, lineKey } from '../../domain/ir/keys' +import { createMasyuPuzzle } from '../../domain/ir/masyu' +import { createSlitherPuzzle } from '../../domain/ir/slither' +import type { PuzzleIR } from '../../domain/ir/types' +import type { InferenceFocus, RuleDiff } from '../../domain/rules/types' + +export type RuleExampleData = { + puzzle: PuzzleIR + before?: RuleDiff[] + after: RuleDiff[] + highlights?: InferenceFocus + explanation: string +} + +const createWhitePearlExample = (): RuleExampleData => { + const puzzle = createMasyuPuzzle(3, 3) + puzzle.cells[cellKey(1, 1)] = { clue: { kind: 'pearl', color: 'white' } } + const left = lineKey([1, 0], [1, 1]) + const right = lineKey([1, 1], [1, 2]) + const up = lineKey([0, 1], [1, 1]) + const down = lineKey([1, 1], [2, 1]) + return { + puzzle, + before: [{ kind: 'line', lineKey: left, from: 'unknown', to: 'line' }], + after: [ + { kind: 'line', lineKey: right, from: 'unknown', to: 'line' }, + { kind: 'line', lineKey: up, from: 'unknown', to: 'blank' }, + { kind: 'line', lineKey: down, from: 'unknown', to: 'blank' }, + ], + highlights: { cells: [cellKey(1, 1)], lines: [right, up, down] }, + explanation: + 'A known horizontal exit forces the white pearl to continue horizontally and rejects both vertical exits.', + } +} + +const createBlackPearlExample = (): RuleExampleData => { + const puzzle = createMasyuPuzzle(4, 3) + puzzle.cells[cellKey(2, 1)] = { clue: { kind: 'pearl', color: 'black' } } + const up = lineKey([1, 1], [2, 1]) + const upExtension = lineKey([0, 1], [1, 1]) + const down = lineKey([2, 1], [3, 1]) + return { + puzzle, + before: [{ kind: 'line', lineKey: up, from: 'unknown', to: 'line' }], + after: [ + { kind: 'line', lineKey: upExtension, from: 'unknown', to: 'line' }, + { kind: 'line', lineKey: down, from: 'unknown', to: 'blank' }, + ], + highlights: { cells: [cellKey(2, 1)], lines: [upExtension, down] }, + explanation: + 'Once the loop exits upward from a black pearl, it must continue straight for another cell and cannot also exit downward.', + } +} + +const createVertexDegreeExample = (): RuleExampleData => { + const puzzle = createSlitherPuzzle(2, 2) + const top = edgeKey([0, 1], [1, 1]) + const left = edgeKey([1, 0], [1, 1]) + const right = edgeKey([1, 1], [1, 2]) + const bottom = edgeKey([1, 1], [2, 1]) + return { + puzzle, + before: [ + { kind: 'edge', edgeKey: top, from: 'unknown', to: 'line' }, + { kind: 'edge', edgeKey: left, from: 'unknown', to: 'line' }, + ], + after: [ + { kind: 'edge', edgeKey: right, from: 'unknown', to: 'blank' }, + { kind: 'edge', edgeKey: bottom, from: 'unknown', to: 'blank' }, + ], + highlights: { edges: [right, bottom], vertices: ['1,1'] }, + explanation: + 'A loop vertex already containing two used edges cannot accept either remaining edge.', + } +} + +export const ruleExamples: Record = { + 'masyu:white-pearl-rule': createWhitePearlExample(), + 'masyu:black-pearl-rule': createBlackPearlExample(), + 'slitherlink:vertex-degree': createVertexDegreeExample(), +} diff --git a/src/features/explanation/BranchInspector.tsx b/src/features/explanation/BranchInspector.tsx new file mode 100644 index 0000000..f911470 --- /dev/null +++ b/src/features/explanation/BranchInspector.tsx @@ -0,0 +1,187 @@ +import { useEffect, useMemo, useState } from 'react' +import { applyRuleDiffs } from '../../domain/rules/engine' +import type { InferenceBranch, InferenceDetails } from '../../domain/rules/types' +import { CanvasBoard } from '../board/CanvasBoard' +import type { DisplaySettings } from '../solver/solverStore' + +type Props = { + details: InferenceDetails + onClose: () => void +} + +const inspectorDisplaySettings: DisplaySettings = { + showGridLabels: true, + showHighlights: true, + showCellColors: true, + showEdgeCrosses: true, + showSectorMarks: true, + showVertices: true, + showCoordinates: false, +} + +const buildBranchPuzzle = (details: InferenceDetails, branch: InferenceBranch, pointer: number) => { + let puzzle = details.basePuzzle + if (pointer >= 1) { + puzzle = applyRuleDiffs(puzzle, branch.initialDiffs) + } + for (let index = 0; index < pointer - 1; index += 1) { + puzzle = applyRuleDiffs(puzzle, branch.traceSteps[index].diffs) + } + return puzzle +} + +export const BranchInspector = ({ details, onClose }: Props) => { + const [branchId, setBranchId] = useState(details.defaultBranchId) + const [pointer, setPointer] = useState(0) + const branch = details.branches.find((item) => item.id === branchId) ?? details.branches[0] + const maxPointer = branch.traceSteps.length + 1 + const puzzle = useMemo( + () => buildBranchPuzzle(details, branch, pointer), + [branch, details, pointer], + ) + const activeTraceStep = pointer >= 2 ? branch.traceSteps[pointer - 2] : undefined + const showContradiction = pointer === maxPointer && branch.status === 'contradiction' + const isForcedConclusion = branch.role === 'forced-conclusion' + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onClose]) + + const selectBranch = (nextBranchId: string) => { + setBranchId(nextBranchId) + setPointer(0) + } + + const stageText = + pointer === 0 + ? 'Base puzzle before the inference' + : pointer === 1 + ? isForcedConclusion + ? 'Apply the conclusion forced by contradiction' + : 'Apply the branch assumption' + : activeTraceStep?.message ?? 'Branch result' + + return ( +
event.target === event.currentTarget && onClose()}> +
+
+
+

Branch Inspector

+

+ {details.conclusion === 'opposite-branch' + ? details.kind === 'masyu-strong' + ? 'The trial assumption contradicts the puzzle, forcing the conclusion.' + : 'One branch contradicts the puzzle, forcing the alternative.' + : 'Both branches imply the same consequence.'} +

+
+ +
+ +
+ {details.branches.map((item) => ( + + ))} +
+ +
+
+ + diff.kind === 'cell' ? [diff.cellKey] : [], + ) ?? [] + } + highlightedColorTiles={activeTraceStep?.affectedTiles ?? []} + highlightedEdges={activeTraceStep?.affectedEdges ?? []} + highlightedLines={activeTraceStep?.affectedLines ?? []} + displaySettings={inspectorDisplaySettings} + onSetDisplayOption={() => {}} + variant="surface" + inferenceDiffs={pointer >= 1 ? branch.initialDiffs : []} + inferenceDiffRole={isForcedConclusion ? 'conclusion' : 'assumption'} + contradictionFocus={showContradiction ? branch.contradiction : undefined} + ariaLabel={`${details.basePuzzle.puzzleType === 'masyu' ? 'Masyu' : 'Slitherlink'} branch inspector canvas`} + /> +
+ + +
+ +
+
+ + + {pointer} / {maxPointer} + +
+
+
+
+ ) +} diff --git a/src/features/explanation/ExplanationPanel.tsx b/src/features/explanation/ExplanationPanel.tsx index 8aeccef..0a39284 100644 --- a/src/features/explanation/ExplanationPanel.tsx +++ b/src/features/explanation/ExplanationPanel.tsx @@ -1,7 +1,11 @@ import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import type { RuleStep } from '../../domain/rules/types' +import { getRuleDocPath } from '../docs/ruleDocRegistry' +import { BranchInspector } from './BranchInspector' type Props = { + pluginId: string steps: RuleStep[] } @@ -19,8 +23,9 @@ const buildStepMeta = (step: RuleStep): string => { return parts.length > 0 ? parts.join(', ') : 'edge updates: 0' } -export const ExplanationPanel = ({ steps }: Props) => { +export const ExplanationPanel = ({ pluginId, steps }: Props) => { const [showAllSteps, setShowAllSteps] = useState(false) + const [inspectedStep, setInspectedStep] = useState(null) const visibleEntries = useMemo( () => (showAllSteps ? steps : steps.slice(-30)) @@ -62,14 +67,40 @@ export const ExplanationPanel = ({ steps }: Props) => { data-active={index === 0} >

- {sequence}. {step.ruleName} + {sequence}.{' '} + + {step.ruleName} + +

+

+ {step.inferenceDetails ? ( + <> + {' '} + + ) : null} + {step.message}

-

{step.message}

{buildStepMeta(step)}

)) )} + {inspectedStep?.inferenceDetails ? ( + setInspectedStep(null)} + /> + ) : null} ) } diff --git a/src/features/puzzlePreview/PuzzlePreviewBoard.test.tsx b/src/features/puzzlePreview/PuzzlePreviewBoard.test.tsx new file mode 100644 index 0000000..0ae48ce --- /dev/null +++ b/src/features/puzzlePreview/PuzzlePreviewBoard.test.tsx @@ -0,0 +1,43 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cellKey } from '../../domain/ir/keys' +import { createMasyuPuzzle } from '../../domain/ir/masyu' +import { PuzzlePreviewBoard } from './PuzzlePreviewBoard' + +describe('PuzzlePreviewBoard', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('draws Masyu pearls in compact previews', () => { + const puzzle = createMasyuPuzzle(25, 25) + puzzle.cells[cellKey(2, 3)] = { clue: { kind: 'pearl', color: 'white' } } + puzzle.cells[cellKey(10, 12)] = { clue: { kind: 'pearl', color: 'black' } } + const arc = vi.fn() + + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fill: vi.fn(), + arc, + } as unknown as CanvasRenderingContext2D) + + render( + , + ) + + expect(arc).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/features/puzzlePreview/PuzzlePreviewBoard.tsx b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx index c3036be..59987e8 100644 --- a/src/features/puzzlePreview/PuzzlePreviewBoard.tsx +++ b/src/features/puzzlePreview/PuzzlePreviewBoard.tsx @@ -74,6 +74,25 @@ const drawPuzzlePreview = ( } } + if (puzzle.puzzleType === 'masyu') { + for (const [key, cell] of Object.entries(puzzle.cells)) { + if (cell.clue?.kind !== 'pearl') { + continue + } + const [row, col] = parseCellKey(key) + const centerX = offsetX + col * cellSize + cellSize / 2 + const centerY = offsetY + row * cellSize + cellSize / 2 + const radius = Math.max(1.2, cellSize * 0.28) + ctx.beginPath() + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) + ctx.fillStyle = cell.clue.color === 'black' ? '#111827' : '#ffffff' + ctx.fill() + ctx.strokeStyle = '#111827' + ctx.lineWidth = Math.max(0.8, cellSize * 0.08) + ctx.stroke() + } + } + for (const [edge, state] of Object.entries(puzzle.edges)) { const [v1, v2] = parseEdgeKey(edge) const x1 = offsetX + v1[1] * cellSize @@ -83,7 +102,9 @@ const drawPuzzlePreview = ( if (state.mark === 'line') { ctx.strokeStyle = '#0284c7' - ctx.lineWidth = isCompact ? Math.max(1.2, cellSize * 0.08) : Math.max(2, cellSize * 0.08) + ctx.lineWidth = isCompact + ? Math.max(1.2, cellSize * 0.08) + : Math.max(2, cellSize * 0.08) ctx.beginPath() ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) @@ -91,9 +112,13 @@ const drawPuzzlePreview = ( } else if (state.mark === 'blank') { const midX = (x1 + x2) / 2 const midY = (y1 + y2) / 2 - const crossSize = isCompact ? Math.max(1.8, cellSize * 0.16) : Math.max(3, cellSize * 0.18) + const crossSize = isCompact + ? Math.max(1.8, cellSize * 0.16) + : Math.max(3, cellSize * 0.18) ctx.strokeStyle = '#94a3b8' - ctx.lineWidth = isCompact ? Math.max(1, cellSize * 0.05) : Math.max(1.5, cellSize * 0.05) + ctx.lineWidth = isCompact + ? Math.max(1, cellSize * 0.05) + : Math.max(1.5, cellSize * 0.05) ctx.beginPath() ctx.moveTo(midX - crossSize, midY - crossSize) ctx.lineTo(midX + crossSize, midY + crossSize) @@ -103,7 +128,8 @@ const drawPuzzlePreview = ( } } - const shouldDrawVertices = !isCompact || cellSize >= 7 + const shouldDrawVertices = + puzzle.puzzleType !== 'masyu' && (!isCompact || cellSize >= 7) if (shouldDrawVertices) { ctx.fillStyle = isCompact ? '#475569' : '#111827' const vertexRadius = isCompact @@ -112,7 +138,13 @@ const drawPuzzlePreview = ( for (let row = 0; row <= puzzle.rows; row += 1) { for (let col = 0; col <= puzzle.cols; col += 1) { ctx.beginPath() - ctx.arc(offsetX + col * cellSize, offsetY + row * cellSize, vertexRadius, 0, Math.PI * 2) + ctx.arc( + offsetX + col * cellSize, + offsetY + row * cellSize, + vertexRadius, + 0, + Math.PI * 2, + ) ctx.fill() } } diff --git a/src/features/solver/solverStore.test.ts b/src/features/solver/solverStore.test.ts index a71066a..8c6f7ea 100644 --- a/src/features/solver/solverStore.test.ts +++ b/src/features/solver/solverStore.test.ts @@ -281,11 +281,11 @@ describe('solver timeline behavior', () => { useSolverStore.getState().goToStep(0) expect(useSolverStore.getState().traceStatsCache.points).toHaveLength(2) - expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 0).current.edgeCoverageRatio).toBe(0) + expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 0).current.coverageRatios.edge).toBe(0) useSolverStore.getState().goToStep(1) - expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 1).current.edgeCoverageRatio).toBe(0.25) + expect(buildTraceStatsView(useSolverStore.getState().traceStatsCache, 1).current.coverageRatios.edge).toBe(0.25) }) it('jumps across a large replay trace and matches a full rebuild', () => { diff --git a/src/features/solver/solverStore.ts b/src/features/solver/solverStore.ts index c7473e6..27bed05 100644 --- a/src/features/solver/solverStore.ts +++ b/src/features/solver/solverStore.ts @@ -60,6 +60,7 @@ type SolverStore = { terminalReport: TerminalSolveReport | null displaySettings: DisplaySettings loadPuzzle: (puzzle: PuzzleIR, options?: LoadPuzzleOptions) => void + loadDefaultPuzzle: (importError?: string) => void importFromUrl: (url: string, pluginId?: string) => void setSourceUrl: (url: string) => void setPluginId: (pluginId: string) => void @@ -316,6 +317,16 @@ export const useSolverStore = create((set, get) => ({ displaySettings: mergeDisplaySettings(nextPluginId, get().displaySettings), }) }, + loadDefaultPuzzle: (importError) => { + const puzzle = getSamplePuzzle() + get().loadPuzzle(puzzle, { + pluginId: 'slitherlink', + sourceUrl: DEFAULT_SLITHERLINK_SAMPLE_URL, + }) + if (importError) { + set({ importError }) + } + }, importFromUrl: (url, pluginId) => { const activePluginId = pluginId ?? get().pluginId const plugin = puzzleRegistry.get(activePluginId) diff --git a/src/features/stats/StatsPanel.tsx b/src/features/stats/StatsPanel.tsx index b45d62f..914d72d 100644 --- a/src/features/stats/StatsPanel.tsx +++ b/src/features/stats/StatsPanel.tsx @@ -2,10 +2,11 @@ import { useMemo, useState } from 'react' import { buildTraceStatsView, type TraceStatsCache, - type TraceChartPoint, } from '../../domain/difficulty/traceStats' +import { puzzleRegistry } from '../../domain/plugins/registry' type Props = { + pluginId: string traceStatsCache: TraceStatsCache pointer: number isRunning: boolean @@ -18,6 +19,8 @@ type ChartSeries = { values: number[] } +type ChartInterpolation = 'linear' | 'step-after' + const formatPercent = (ratio: number): string => `${(ratio * 100).toFixed(1)}%` const formatDuration = (durationMs: number): string => { @@ -48,7 +51,8 @@ const formatStepSummary = (stepNumbers: number[]): string => { : `${stepNumbers.length} hits, steps ${first}-${last}` } -const clampRatio = (value: number): number => Math.min(1, Math.max(0, value)) +const clampValue = (value: number, maxValue: number): number => + Math.min(maxValue, Math.max(0, value)) const MAX_RENDERED_POINTS = 400 @@ -64,11 +68,21 @@ const sampleChartValues = (values: number[]): Array<{ index: number; value: numb return sampled.filter((item, index, arr) => index === 0 || item.index !== arr[index - 1].index) } +const accumulateValues = (values: number[]): number[] => { + let total = 0 + return values.map((value) => { + total += value + return total + }) +} + const makePath = ( values: number[], + maxValue: number, width: number, height: number, padding: { top: number; right: number; bottom: number; left: number }, + interpolation: ChartInterpolation, ): string => { if (values.length === 0) { return '' @@ -78,10 +92,19 @@ const makePath = ( const plotHeight = height - padding.top - padding.bottom const xFor = (index: number): number => padding.left + (values.length <= 1 ? 0 : (index / (values.length - 1)) * plotWidth) - const yFor = (value: number): number => padding.top + (1 - clampRatio(value)) * plotHeight - return sampled - .map(({ index, value }, sampledIndex) => `${sampledIndex === 0 ? 'M' : 'L'} ${xFor(index)} ${yFor(value)}`) - .join(' ') + const yFor = (value: number): number => + padding.top + (1 - clampValue(value, maxValue) / maxValue) * plotHeight + return sampled.reduce((path, point, sampledIndex) => { + const command = `${xFor(point.index)} ${yFor(point.value)}` + if (sampledIndex === 0) { + return `M ${command}` + } + if (interpolation === 'step-after') { + const previous = sampled[sampledIndex - 1] + return `${path} L ${xFor(point.index)} ${yFor(previous.value)} L ${command}` + } + return `${path} L ${command}` + }, '') } const TraceLineChart = ({ @@ -89,11 +112,19 @@ const TraceLineChart = ({ description, series, currentIndex, + maxValue, + formatValue, + interpolation = 'linear', + formatLegendValue, }: { title: string description: string series: ChartSeries[] currentIndex: number + maxValue: number + formatValue: (value: number) => string + interpolation?: ChartInterpolation + formatLegendValue?: (series: ChartSeries, currentIndex: number) => string }) => { const width = 360 const height = 190 @@ -104,7 +135,9 @@ const TraceLineChart = ({ const plotHeight = height - padding.top - padding.bottom const xFor = (index: number): number => padding.left + (maxLength <= 1 ? 0 : (index / (maxLength - 1)) * plotWidth) - const yFor = (value: number): number => padding.top + (1 - clampRatio(value)) * plotHeight + const safeMaxValue = Math.max(1, maxValue) + const yFor = (value: number): number => + padding.top + (1 - clampValue(value, safeMaxValue) / safeMaxValue) * plotHeight return (
@@ -121,10 +154,10 @@ const TraceLineChart = ({ - 100% + {formatValue(maxValue)} - 0% + {formatValue(0)} 0 @@ -135,7 +168,8 @@ const TraceLineChart = ({ {series.map((item) => ( ( - {item.label} {formatPercent(item.values[clampedIndex] ?? 0)} + {item.label}{' '} + {formatLegendValue?.(item, clampedIndex) ?? formatValue(item.values[clampedIndex] ?? 0)} ))}
@@ -177,32 +212,6 @@ const TraceLineChart = ({ ) } -const getBoardProgressSeries = (points: TraceChartPoint[]): ChartSeries[] => [ - { - label: 'Progress', - color: '#0891b2', - values: points.map((point) => point.boardProgressRatio), - }, -] - -const getCoverageSeries = (points: TraceChartPoint[]): ChartSeries[] => [ - { - label: 'Edge', - color: '#2563eb', - values: points.map((point) => point.edgeCoverageRatio), - }, - { - label: 'Cell', - color: '#16a34a', - values: points.map((point) => point.cellCoverageRatio), - }, - { - label: 'Vertex', - color: '#d97706', - values: points.map((point) => point.vertexCoverageRatio), - }, -] - const MAX_RULE_BARS = 8 const RuleUsageBars = ({ rules }: { rules: ReturnType['rules'] }) => { @@ -245,14 +254,47 @@ const RuleUsageBars = ({ rules }: { rules: ReturnType { +export const StatsPanel = ({ pluginId, traceStatsCache, pointer, isRunning, onGoToStep }: Props) => { const stats = useMemo(() => buildTraceStatsView(traceStatsCache, pointer), [pointer, traceStatsCache]) const [showRuleDetails, setShowRuleDetails] = useState(false) - const boardProgressSeries = useMemo( - () => getBoardProgressSeries(traceStatsCache.points), + const liveStats = puzzleRegistry.get(pluginId)?.liveStats + const coverageSeries = useMemo( + () => + (liveStats?.coverageSeries ?? []).map((series) => ({ + label: series.label, + color: series.color, + values: traceStatsCache.points.map((point) => point.coverageRatios[series.source]), + })), + [liveStats, traceStatsCache], + ) + const durationIncrements = useMemo( + () => [ + { + label: 'Full Step', + color: '#dc2626', + values: traceStatsCache.points.map((point) => point.stepDurationMs), + }, + { + label: 'Matched Rule', + color: '#0891b2', + values: traceStatsCache.points.map((point) => point.ruleApplyMs), + }, + ], [traceStatsCache], ) - const coverageSeries = useMemo(() => getCoverageSeries(traceStatsCache.points), [traceStatsCache]) + const durationSeries = useMemo( + () => + durationIncrements.map((series) => ({ + ...series, + values: accumulateValues(series.values), + })), + [durationIncrements], + ) + const maxDurationMs = Math.max(1, ...durationSeries.flatMap((series) => series.values)) + const formatDurationLegend = (series: ChartSeries, currentIndex: number): string => { + const increments = durationIncrements.find((item) => item.label === series.label) + return `${formatDuration(series.values[currentIndex] ?? 0)} (+${formatDuration(increments?.values[currentIndex] ?? 0)} this step)` + } return (
@@ -296,16 +338,22 @@ export const StatsPanel = ({ traceStatsCache, pointer, isRunning, onGoToStep }:
diff --git a/src/mdx.d.ts b/src/mdx.d.ts new file mode 100644 index 0000000..c9665f3 --- /dev/null +++ b/src/mdx.d.ts @@ -0,0 +1,6 @@ +declare module '*.mdx' { + import type { ComponentType } from 'react' + + const MDXContent: ComponentType + export default MDXContent +} diff --git a/tests/e2e/workspace.spec.ts b/tests/e2e/workspace.spec.ts index 670207c..68bba91 100644 --- a/tests/e2e/workspace.spec.ts +++ b/tests/e2e/workspace.spec.ts @@ -2,6 +2,11 @@ import { expect, test } from '@playwright/test' test('imports puzzle and applies next step', async ({ page }) => { await page.goto('/puzzlekit-web/') + await expect(page).toHaveTitle('PuzzleKit Web') + await expect(page.locator('link[rel="icon"]')).toHaveAttribute( + 'href', + '/puzzlekit-web/favicon.svg', + ) await expect(page.getByRole('heading', { name: 'PuzzleKit Web' })).toBeVisible() await page.getByRole('button', { name: 'Import URL' }).click() await page.getByRole('button', { name: 'Next Step' }).click() @@ -16,3 +21,54 @@ test('loads an editor preset into the solver', async ({ page }) => { await expect(page.getByRole('heading', { name: 'PuzzleKit Web' })).toBeVisible() await expect(page.getByText('10 × 10')).toBeVisible() }) + +test('loads and reloads a canonical puzz.link payload deep link', async ({ page }) => { + const payload = + 'slither/10/10/q2111221ch6212b212611b61262cg1c6bb2121c2bcc621112bo' + const sourceUrl = `https://puzz.link/p?${payload}` + const urlInput = page.getByPlaceholder('Paste puzz.link, pzplus, pzv, or penpa URL') + + await page.goto(`/puzzlekit-web/?p=${payload}`) + await expect(urlInput).toHaveValue(sourceUrl) + await expect(page.getByText('10 × 10')).toBeVisible() + + await page.reload() + await expect(urlInput).toHaveValue(sourceUrl) + await expect(page.getByText('10 × 10')).toBeVisible() +}) + +test('falls back and reports an invalid deep link', async ({ page }) => { + await page.goto('/puzzlekit-web/?p=slither/10/10/dsew%3F') + + await expect(page.getByRole('alertdialog', { name: 'Import failed' })).toBeVisible() + await expect(page.getByPlaceholder('Paste puzz.link, pzplus, pzv, or penpa URL')).toHaveValue( + /https:\/\/puzz\.link\/p\?slither\/18\/10\//, + ) + await expect(page).toHaveURL(/p=slither\/10\/10\/dsew%3F/) +}) + +test('keeps the docs header aligned and compact across docs routes', async ({ page }) => { + await page.goto('/puzzlekit-web/') + const solverHeader = page.locator('.workspace-title') + const solverHeaderWidth = await solverHeader.evaluate( + (element) => element.getBoundingClientRect().width, + ) + + await page.goto('/puzzlekit-web/docs') + const docsHeader = page.locator('.workspace-title') + const docsIndexHeaderSize = await docsHeader.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { height: rect.height, width: rect.width } + }) + + expect(docsIndexHeaderSize.width).toBeCloseTo(solverHeaderWidth, 0) + + await page.goto( + '/puzzlekit-web/docs/slitherlink/rules/diagonal-adjacent-three-outer-corners', + ) + const ruleHeaderHeight = await page + .locator('.workspace-title') + .evaluate((element) => element.getBoundingClientRect().height) + + expect(ruleHeaderHeight).toBeCloseTo(docsIndexHeaderSize.height, 0) +}) diff --git a/vite.config.ts b/vite.config.ts index 7f70d68..3466dae 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' +import mdx from '@mdx-js/rollup' // https://vite.dev/config/ export default defineConfig({ base: '/puzzlekit-web/', - plugins: [react()], + plugins: [mdx(), react()], test: { environment: 'jsdom', setupFiles: './src/test/setup.ts',