feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
release/*.zip
release/*.zip.sha256
coverage/
playwright-report/
test-results/
*.tsbuildinfo
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
always-auth=false
+14
View File
@@ -0,0 +1,14 @@
# Changelog
All notable changes are documented here.
## Unreleased
## 0.1.0 - 2026-08-30
- Initial local-first Sudoku setting, playing, solving and analysis workbench.
- Added bounded classic and variant puzzle models, validation and exact solving.
- Added explainable human-style deductions and full solve-path inspection.
- Added Killer combination, relation and house-analysis helpers.
- Added local projects, progress, import/export and compact share fragments.
- Added deterministic packaging, browser tests and Toolbox SDK integration.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+7
View File
@@ -0,0 +1,7 @@
# Licence inventory
Sudoku Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
`THIRD_PARTY_NOTICES.md` identifies shipped runtime packages. Release
preparation collates their exact installed licence files into
`npm-runtime-licenses.txt`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013 pieroxy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
# Sudoku Tools
Sudoku Tools is a standalone, local-first browser workbench for setting,
playing, analysing and solving Sudoku puzzles. It belongs to the
[add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal), but every
release also runs independently from any static HTTPS host or local preview.
## Workspaces
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
local progress.
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
and seeded classic puzzle generation.
- **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
residuals, and Kropki, XV or inequality relation pairs.
Classic grids and common variants share one bounded puzzle model: irregular
regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues,
inequalities, renban lines, palindromes, anti-knight, anti-king and
non-consecutive rules. Import accepts compact grid strings, this project's JSON
format, share fragments, and the common f-puzzles fields supported by the
model. Unknown constraints are reported rather than silently discarded.
## Privacy and storage
Puzzle parsing, solving, generation and export happen entirely in the browser.
There are no analytics, advertisements, accounts or network-backed puzzle
lookups. Projects and progress are saved only in local IndexedDB after an
explicit save action and can be cleared from inside the app.
Exported files and share URLs contain the puzzle data you chose to include.
SudokuPad short IDs identify server-hosted records and therefore cannot be
resolved by this deliberately offline application. Embedded f-puzzles payloads
are accepted without contacting their originating site.
## Development
Requirements: Node.js 22 or newer and npm 11 or newer.
```sh
npm ci
npm run check
npm run test:browser
npm run dev
```
Vite uses `base: './'`, so `dist/` works at `/` and at nested Toolbox paths.
`toolbox-check` validates both standalone and contextual operation.
## Release
```sh
npm run release:artifact
```
The command verifies the manifest, types, formatting, tests, production build,
browser workflows and Toolbox contract before creating a deterministic
`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
## Scope and references
The interaction ideas and published solving concepts explored by SudokuPad,
SudokuWiki and the wider puzzle community informed the product requirements.
No code, artwork, wording or proprietary puzzle data from those applications is
included. Algorithms and explanations in this repository are independently
implemented from mathematical rules and public standards.
## Licence
Sudoku Tools is free software under `GPL-3.0-or-later`. See [LICENSE](LICENSE).
+36
View File
@@ -0,0 +1,36 @@
# Security and privacy
## Local-first boundary
Sudoku Tools has no application network client, remote puzzle resolver,
telemetry or account system. File and text imports are read locally. The
Toolbox shell may fetch its same-origin catalogue when the app is launched from
the Portal; that catalogue never receives puzzle contents.
Browser developer tools, extensions, a compromised host page, or any other
script trusted on the same origin remain outside this application's security
boundary. Exported files and URL fragments are plaintext puzzle data.
## Untrusted input
Imported text, JSON, URLs and local database records are untrusted. Parsers use
explicit schemas and limits for input bytes, dimensions, cells, constraints,
metadata and saved records. Unknown variant constraints fail visibly. User text
is rendered as React text and is never inserted as HTML. Imported links are not
opened automatically.
Solver and generator searches are bounded, cancellable and run in a worker.
Solution counting stops after the requested cap—two by default for uniqueness.
The UI remains usable if a worker is cancelled or fails.
## Persistence
IndexedDB records are versioned and bounded. Saving is explicit; deleting a
project or clearing the local library removes only this application's records.
Private browsing, browser storage pressure and profile clearing may erase them,
so important puzzles should be exported.
## Reporting
Please report vulnerabilities privately to the repository owner before opening
a public issue when disclosure could expose users.
+9
View File
@@ -0,0 +1,9 @@
# Corresponding source
The corresponding source for Sudoku Tools 0.1.0 is available at:
https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.1.0
The production archive is generated from that tag with the scripts and exact
dependency lock contained in the repository. It contains source identity,
licence and third-party notices beside the executable application.
+19
View File
@@ -0,0 +1,19 @@
# Third-party notices
Release preparation collates licence files for the exact locked runtime
packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact.
| Package | Version | Licence | Role | Source |
| -------------------------------- | ------- | ---------- | ------------------------------- | ------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `lz-string` | 1.5.0 | MIT | compact local share payloads | https://github.com/pieroxy/lz-string |
| `react` | 19.2.8 | MIT | application UI | https://github.com/facebook/react |
| `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react |
`scheduler`, distributed by the React project under MIT, is the only transitive
runtime package. Development-only packages build and test the source but are
not shipped as runtime modules.
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source,
documentation, wording, visual assets and puzzle data are not bundled or copied.
+40
View File
@@ -0,0 +1,40 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist",
"release",
"coverage",
"test-results",
"playwright-report",
],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2023,
globals: { ...globals.browser, ...globals.worker },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
{
files: ["scripts/**/*.mjs", "playwright.config.ts"],
languageOptions: { globals: globals.node },
},
);
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Set, play, solve and analyse Sudoku puzzles locally in your browser."
/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<title>Sudoku Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+4012
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "sudoku-tools",
"version": "0.1.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/lotobo/sudoku-tools.git"
},
"homepage": "https://git.add-ideas.de/lotobo/sudoku-tools",
"bugs": {
"url": "https://git.add-ideas.de/lotobo/sudoku-tools/issues"
},
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"predev": "npm run manifest:generate",
"dev": "vite",
"prebuild": "npm run release:prepare && npm run manifest:generate",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"lint": "eslint . --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"test:browser": "playwright test",
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
"release:prepare": "node scripts/prepare-release-files.mjs",
"toolbox:check": "toolbox-check dist",
"package:release": "node scripts/package-release.mjs",
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"lz-string": "1.5.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/node": "25.9.5",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"eslint": "10.7.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.2",
"fast-check": "4.9.0",
"globals": "17.7.0",
"jsdom": "29.1.1",
"prettier": "3.9.5",
"typescript": "6.0.3",
"typescript-eslint": "8.64.0",
"vite": "8.2.2",
"vitest": "4.1.11"
},
"packageManager": "npm@11.17.0"
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/browser",
fullyParallel: false,
workers: 2,
timeout: 120_000,
expect: { timeout: 10_000 },
reporter: [["list"]],
use: {
baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure",
},
webServer: {
command: "npm run build && node scripts/serve-test.mjs",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
});
+14
View File
@@ -0,0 +1,14 @@
# Changelog
All notable changes are documented here.
## Unreleased
## 0.1.0 - 2026-08-30
- Initial local-first Sudoku setting, playing, solving and analysis workbench.
- Added bounded classic and variant puzzle models, validation and exact solving.
- Added explainable human-style deductions and full solve-path inspection.
- Added Killer combination, relation and house-analysis helpers.
- Added local projects, progress, import/export and compact share fragments.
- Added deterministic packaging, browser tests and Toolbox SDK integration.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+7
View File
@@ -0,0 +1,7 @@
# Licence inventory
Sudoku Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
`THIRD_PARTY_NOTICES.md` identifies shipped runtime packages. Release
preparation collates their exact installed licence files into
`npm-runtime-licenses.txt`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013 pieroxy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+515
View File
@@ -0,0 +1,515 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
Declared licence: Apache-2.0
Installed from: node_modules/@add-ideas/toolbox-contract
==============================================================================
--- LICENSE ---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
Copyright 2026 ADD Ideas
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
Declared licence: Apache-2.0
Installed from: node_modules/@add-ideas/toolbox-shell-react
==============================================================================
--- LICENSE ---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
Copyright 2026 ADD Ideas
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==============================================================================
lz-string@1.5.0
Declared licence: MIT
Installed from: node_modules/lz-string
==============================================================================
--- LICENSE ---
MIT License
Copyright (c) 2013 pieroxy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
==============================================================================
react@19.2.8
Declared licence: MIT
Installed from: node_modules/react
==============================================================================
--- LICENSE ---
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
==============================================================================
react-dom@19.2.8
Declared licence: MIT
Installed from: node_modules/react-dom
==============================================================================
--- LICENSE ---
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
==============================================================================
scheduler@0.27.0
Declared licence: MIT
Installed from: node_modules/scheduler
==============================================================================
--- LICENSE ---
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
# Sudoku Tools
Sudoku Tools is a standalone, local-first browser workbench for setting,
playing, analysing and solving Sudoku puzzles. It belongs to the
[add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal), but every
release also runs independently from any static HTTPS host or local preview.
## Workspaces
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
local progress.
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
and seeded classic puzzle generation.
- **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
residuals, and Kropki, XV or inequality relation pairs.
Classic grids and common variants share one bounded puzzle model: irregular
regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues,
inequalities, renban lines, palindromes, anti-knight, anti-king and
non-consecutive rules. Import accepts compact grid strings, this project's JSON
format, share fragments, and the common f-puzzles fields supported by the
model. Unknown constraints are reported rather than silently discarded.
## Privacy and storage
Puzzle parsing, solving, generation and export happen entirely in the browser.
There are no analytics, advertisements, accounts or network-backed puzzle
lookups. Projects and progress are saved only in local IndexedDB after an
explicit save action and can be cleared from inside the app.
Exported files and share URLs contain the puzzle data you chose to include.
SudokuPad short IDs identify server-hosted records and therefore cannot be
resolved by this deliberately offline application. Embedded f-puzzles payloads
are accepted without contacting their originating site.
## Development
Requirements: Node.js 22 or newer and npm 11 or newer.
```sh
npm ci
npm run check
npm run test:browser
npm run dev
```
Vite uses `base: './'`, so `dist/` works at `/` and at nested Toolbox paths.
`toolbox-check` validates both standalone and contextual operation.
## Release
```sh
npm run release:artifact
```
The command verifies the manifest, types, formatting, tests, production build,
browser workflows and Toolbox contract before creating a deterministic
`release/sudoku-tools-0.1.0.zip` with a matching SHA-256 sidecar.
## Scope and references
The interaction ideas and published solving concepts explored by SudokuPad,
SudokuWiki and the wider puzzle community informed the product requirements.
No code, artwork, wording or proprietary puzzle data from those applications is
included. Algorithms and explanations in this repository are independently
implemented from mathematical rules and public standards.
## Licence
Sudoku Tools is free software under `GPL-3.0-or-later`. See [LICENSE](LICENSE).
+36
View File
@@ -0,0 +1,36 @@
# Security and privacy
## Local-first boundary
Sudoku Tools has no application network client, remote puzzle resolver,
telemetry or account system. File and text imports are read locally. The
Toolbox shell may fetch its same-origin catalogue when the app is launched from
the Portal; that catalogue never receives puzzle contents.
Browser developer tools, extensions, a compromised host page, or any other
script trusted on the same origin remain outside this application's security
boundary. Exported files and URL fragments are plaintext puzzle data.
## Untrusted input
Imported text, JSON, URLs and local database records are untrusted. Parsers use
explicit schemas and limits for input bytes, dimensions, cells, constraints,
metadata and saved records. Unknown variant constraints fail visibly. User text
is rendered as React text and is never inserted as HTML. Imported links are not
opened automatically.
Solver and generator searches are bounded, cancellable and run in a worker.
Solution counting stops after the requested cap—two by default for uniqueness.
The UI remains usable if a worker is cancelled or fails.
## Persistence
IndexedDB records are versioned and bounded. Saving is explicit; deleting a
project or clearing the local library removes only this application's records.
Private browsing, browser storage pressure and profile clearing may erase them,
so important puzzles should be exported.
## Reporting
Please report vulnerabilities privately to the repository owner before opening
a public issue when disclosure could expose users.
+9
View File
@@ -0,0 +1,9 @@
# Corresponding source
The corresponding source for Sudoku Tools 0.1.0 is available at:
https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.1.0
The production archive is generated from that tag with the scripts and exact
dependency lock contained in the repository. It contains source identity,
licence and third-party notices beside the executable application.
+19
View File
@@ -0,0 +1,19 @@
# Third-party notices
Release preparation collates licence files for the exact locked runtime
packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact.
| Package | Version | Licence | Role | Source |
| -------------------------------- | ------- | ---------- | ------------------------------- | ------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `lz-string` | 1.5.0 | MIT | compact local share payloads | https://github.com/pieroxy/lz-string |
| `react` | 19.2.8 | MIT | application UI | https://github.com/facebook/react |
| `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react |
`scheduler`, distributed by the React project under MIT, is the only transitive
runtime package. Development-only packages build and test the source but are
not shipped as runtime modules.
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source,
documentation, wording, visual assets and puzzle data are not bundled or copied.
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">Sudoku Tools</title>
<path fill="#f4f0e8" stroke="#272421" stroke-width="3" d="M12 3h29l11 11v47H12z"/>
<path fill="#ded7c9" stroke="#272421" stroke-width="3" d="M41 3v12h11"/>
<g transform="translate(18 22)" fill="none" stroke="#272421">
<rect width="27" height="27" rx="1" fill="#fffdf8" stroke-width="2"/>
<path d="M9 0v27M18 0v27M0 9h27M0 18h27" stroke-width="1"/>
</g>
<g fill="#e65f3c" font-family="system-ui,sans-serif" font-size="7" font-weight="800" text-anchor="middle">
<text x="22.5" y="30">7</text><text x="31.5" y="39">5</text><text x="40.5" y="48">9</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 724 B

+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
"version": "0.1.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["games", "puzzles", "logic"],
"tags": ["sudoku", "killer", "solver", "setter", "generator", "candidates"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": true,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false,
"label": "Puzzles and progress stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/sudoku-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/sudoku-tools"
}
]
}
+56
View File
@@ -0,0 +1,56 @@
import { lstat, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { format } from "prettier";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
const outputPath = join(root, "public", "toolbox-app.json");
const source = JSON.parse(await readFile(sourcePath, "utf8"));
const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const versionSource = await readFile(join(root, "src", "version.ts"), "utf8");
const applicationVersion =
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(versionSource)?.[1];
if (
source.version !== packageJson.version ||
applicationVersion !== packageJson.version
)
throw new Error(
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
);
if (
source.id !== "de.add-ideas.sudoku-tools" ||
source.source?.repository !==
"https://git.add-ideas.de/lotobo/sudoku-tools" ||
source.source?.license !== "GPL-3.0-or-later"
)
throw new Error("Manifest source identity is incomplete or inconsistent");
for (const asset of source.assets ?? []) {
if (
typeof asset !== "string" ||
!asset.startsWith("./") ||
asset.includes("\\") ||
asset.split("/").includes("..")
)
throw new Error(`Unsafe manifest asset path: ${JSON.stringify(asset)}`);
const details = await lstat(join(root, "public", asset.slice(2))).catch(
() => null,
);
if (!details?.isFile() || details.isSymbolicLink())
throw new Error(`Manifest asset is missing or unsafe: ${asset}`);
}
const serialized = await format(JSON.stringify(source), {
filepath: outputPath,
});
if (process.argv.includes("--check")) {
if ((await readFile(outputPath, "utf8").catch(() => "")) !== serialized)
throw new Error(
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
);
console.log("Toolbox manifest is synchronized");
} else {
await writeFile(outputPath, serialized);
console.log(`Generated ${relative(root, outputPath)}`);
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import {
access,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
utimes,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execute = promisify(execFile);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
};
const output = path.resolve(
root,
argument("--output", `release/sudoku-tools-${packageJson.version}.zip`),
);
const checksumOutput = `${output}.sha256`;
const force = process.argv.includes("--force");
if (
path.extname(output).toLowerCase() !== ".zip" ||
output === root ||
output === path.parse(output).root
)
throw new Error("Release output is not a safe ZIP target");
const exists = (file) =>
access(file).then(
() => true,
() => false,
);
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
throw new Error("Release output already exists; use --force to replace it");
const input = path.join(root, "dist");
for (const name of [
"index.html",
"toolbox-app.json",
"favicon.svg",
"README.md",
"CHANGELOG.md",
"LICENSE",
"SOURCE.md",
"SECURITY.md",
"THIRD_PARTY_NOTICES.md",
"LICENSES/README.md",
"LICENSES/npm-runtime-licenses.txt",
]) {
const details = await lstat(path.join(input, name)).catch(() => null);
if (!details?.isFile() || details.isSymbolicLink())
throw new Error(`Release is missing a regular file: ${name}`);
}
const manifest = JSON.parse(
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
);
if (
manifest.id !== "de.add-ideas.sudoku-tools" ||
manifest.version !== packageJson.version ||
manifest.entry !== "./" ||
manifest.icon !== "./favicon.svg"
)
throw new Error("Packaged Toolbox manifest identity is invalid");
const html = await readFile(path.join(input, "index.html"), "utf8");
if (/\b(?:src|href)=["']\//iu.test(html))
throw new Error("index.html contains a root-absolute asset reference");
async function collect(directory, prefix = "") {
const files = [];
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
(left, right) => left.name.localeCompare(right.name),
)) {
const absolute = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink())
throw new Error(`Release contains a symbolic link: ${relative}`);
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
else if (entry.isFile()) files.push({ absolute, relative });
else throw new Error(`Unsupported release entry: ${relative}`);
}
return files;
}
const sourceFiles = await collect(input);
for (const file of sourceFiles)
if (
file.relative.endsWith(".map") ||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
file.relative,
) ||
file.relative.startsWith("/") ||
file.relative.split("/").includes("..")
)
throw new Error(`Forbidden release entry: ${file.relative}`);
await mkdir(path.dirname(output), { recursive: true });
const stagingRoot = await mkdtemp(
path.join(path.dirname(output), ".sudoku-release-"),
);
const stagedTree = path.join(stagingRoot, "tree");
const stagedArchive = path.join(stagingRoot, path.basename(output));
try {
await cp(input, stagedTree, { recursive: true });
const timestamp = new Date("1980-01-01T00:00:00.000Z");
for (const file of await collect(stagedTree))
await utimes(file.absolute, timestamp, timestamp);
await execute(
"zip",
[
"-X",
"-q",
"-9",
stagedArchive,
...sourceFiles.map((file) => file.relative),
],
{ cwd: stagedTree, maxBuffer: 1024 * 1024 },
);
const archive = await readFile(stagedArchive);
const digest = createHash("sha256").update(archive).digest("hex");
const stagedChecksum = `${stagedArchive}.sha256`;
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
mode: 0o644,
});
if (force) {
await rm(output, { force: true });
await rm(checksumOutput, { force: true });
}
await rename(stagedArchive, output);
await rename(stagedChecksum, checksumOutput);
console.log(
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${sourceFiles.length} files)\nSHA-256 ${digest}`,
);
} finally {
await rm(stagingRoot, { recursive: true, force: true });
}
+65
View File
@@ -0,0 +1,65 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const destination = path.join(root, "public");
const required = [
"LICENSE",
"README.md",
"CHANGELOG.md",
"SOURCE.md",
"SECURITY.md",
"THIRD_PARTY_NOTICES.md",
];
await mkdir(destination, { recursive: true });
for (const name of required) {
await readFile(path.join(root, name));
await cp(path.join(root, name), path.join(destination, name));
}
const publicLicenses = path.join(destination, "LICENSES");
await rm(publicLicenses, { recursive: true, force: true });
await cp(path.join(root, "LICENSES"), publicLicenses, { recursive: true });
const lock = JSON.parse(
await readFile(path.join(root, "package-lock.json"), "utf8"),
);
const sections = [];
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
([left], [right]) => left.localeCompare(right),
)) {
if (!location.includes("node_modules/") || locked.dev === true) continue;
const packageDirectory = path.join(root, location);
const details = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
);
const candidates = (await readdir(packageDirectory))
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
.sort();
const texts = [];
for (const candidate of candidates) {
try {
texts.push(
`--- ${candidate} ---\n${await readFile(path.join(packageDirectory, candidate), "utf8")}`,
);
} catch {
/* Ignore directories and non-text aliases. */
}
}
sections.push(
[
"=".repeat(78),
`${details.name}@${details.version}`,
`Declared licence: ${details.license ?? locked.license ?? "See upstream"}`,
`Installed from: ${location}`,
"=".repeat(78),
texts.join("\n\n") ||
"No package-local licence file was present; see THIRD_PARTY_NOTICES.md.",
].join("\n"),
);
}
await writeFile(
path.join(publicLicenses, "npm-runtime-licenses.txt"),
`${sections.join("\n\n")}\n`,
);
console.log("Prepared static release notices");
+72
View File
@@ -0,0 +1,72 @@
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"dist",
);
const nestedPrefix = "/deep/nested/sudoku/";
const mediaTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".md", "text/markdown; charset=utf-8"],
[".txt", "text/plain; charset=utf-8"],
]);
const headers = {
"Content-Security-Policy":
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), usb=()",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
};
function safeFile(requestPath) {
const decoded = decodeURIComponent(requestPath);
const relative = decoded.startsWith(nestedPrefix)
? decoded.slice(nestedPrefix.length)
: decoded.replace(/^\/+/, "");
const normalized = path.posix.normalize(relative || "index.html");
if (
normalized === ".." ||
normalized.startsWith("../") ||
path.isAbsolute(normalized)
)
return null;
return path.join(root, normalized);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
let file = safeFile(url.pathname);
if (!file) {
response.writeHead(400).end("Bad request");
return;
}
if ((await stat(file).catch(() => null))?.isDirectory())
file = path.join(file, "index.html");
const content = await readFile(file);
response.writeHead(200, {
"Content-Type":
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
"Cache-Control": "no-cache",
...headers,
});
response.end(content);
} catch {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
});
server.listen(4173, "127.0.0.1", () =>
console.log("Sudoku Tools test server listening on http://127.0.0.1:4173"),
);
+41
View File
@@ -0,0 +1,41 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
onContextError={(error) =>
console.warn(
"Toolbox context unavailable; continuing standalone.",
error,
)
}
>
<Suspense
fallback={
<p className="workbench-loading" role="status">
Preparing the local Sudoku workbench
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</AppErrorBoundary>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
"Sudoku Tools encountered an unrecoverable interface error",
error,
info,
);
}
render(): ReactNode {
if (!this.state.error) return this.props.children;
return (
<main className="fatal-error" role="alert">
<h1>Sudoku Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button
type="button"
className="primary-button"
onClick={() => globalThis.location.reload()}
>
Reload application
</button>
</main>
);
}
}
+387
View File
@@ -0,0 +1,387 @@
import { useState } from "react";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
interface ConstraintEditorProps {
puzzle: PuzzleDefinition;
selection: readonly number[];
onChange: (puzzle: PuzzleDefinition) => void;
onNewGrid: (size: number) => void;
onCheck: () => void;
onGenerate: () => void;
busy: boolean;
}
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
switch (constraint.type) {
case "diagonal":
return `${constraint.direction} diagonal`;
case "anti-knight":
return "anti-knight";
case "anti-king":
return "anti-king";
case "non-consecutive":
return "non-consecutive";
case "killer-cage":
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
case "thermo":
case "renban":
case "palindrome":
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
case "arrow":
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
case "kropki":
return `${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`;
case "xv":
return `${constraint.total === 5 ? "V" : "X"} · ${cell(constraint.a)}${cell(constraint.b)}`;
case "inequality":
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
}
}
export function ConstraintEditor({
puzzle,
selection,
onChange,
onNewGrid,
onCheck,
onGenerate,
busy,
}: ConstraintEditorProps) {
const [cageSum, setCageSum] = useState(10);
const [region, setRegion] = useState(1);
const constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) =>
onChange({ ...puzzle, constraints: [...constraints, constraint] });
const need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count;
const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive",
) => {
const exists = constraints.some((constraint) => constraint.type === type);
onChange({
...puzzle,
constraints: exists
? constraints.filter((constraint) => constraint.type !== type)
: [...constraints, { type }],
});
};
return (
<div className="setter-panel stack">
<section className="panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Puzzle definition</p>
<h2>Set a puzzle</h2>
</div>
<label className="compact-field">
Grid
<select
value={puzzle.size}
onChange={(event) => onNewGrid(Number(event.target.value))}
>
<option value="4">4 × 4</option>
<option value="6">6 × 6</option>
<option value="9">9 × 9</option>
<option value="12">12 × 12</option>
<option value="16">16 × 16</option>
</select>
</label>
</div>
<div className="field-grid">
<label>
Title
<input
value={puzzle.title ?? ""}
maxLength={256}
onChange={(event) =>
onChange({ ...puzzle, title: event.target.value })
}
/>
</label>
<label>
Setter
<input
value={puzzle.author ?? ""}
maxLength={256}
onChange={(event) =>
onChange({ ...puzzle, author: event.target.value })
}
/>
</label>
</div>
<label>
Rules
<textarea
rows={3}
maxLength={16_384}
value={puzzle.rules ?? ""}
onChange={(event) =>
onChange({ ...puzzle, rules: event.target.value })
}
/>
</label>
</section>
<section className="panel-section">
<p className="eyebrow">Selected cells</p>
<h3>
{selection.length
? `${String(selection.length)} selected`
: "Select cells on the grid"}
</h3>
<p className="muted">
Selection order defines lines and pair direction. Shift-click or drag
to build a selection.
</p>
<div className="inline-fields">
<label className="compact-field">
Cage sum
<input
type="number"
min="1"
max={puzzle.size * puzzle.size}
value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || !Number.isInteger(cageSum)}
onClick={() =>
append({ type: "killer-cage", cells: selection, sum: cageSum })
}
>
Add cage
</button>
</div>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "thermo", cells: selection })}
>
Thermo
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "renban", cells: selection })}
>
Renban
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "palindrome", cells: selection })}
>
Palindrome
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() =>
append({
type: "arrow",
bulb: [selection[0]!],
line: selection.slice(1),
})
}
>
Arrow
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "kropki",
a: selection[0]!,
b: selection[1]!,
kind: "white",
})
}
>
White dot
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "kropki",
a: selection[0]!,
b: selection[1]!,
kind: "black",
})
}
>
Black dot
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 5,
})
}
>
V pair
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 10,
})
}
>
X pair
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[0]!,
greater: selection[1]!,
})
}
>
First &lt; second
</button>
</div>
<div className="inline-fields">
<label className="compact-field">
Region
<input
type="number"
min="1"
max={puzzle.size}
value={region}
onChange={(event) => setRegion(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || region < 1 || region > puzzle.size}
onClick={() => {
const regions = [...(puzzle.regions ?? [])];
for (const cell of selection) regions[cell] = region - 1;
onChange({ ...puzzle, regions });
}}
>
Paint region
</button>
</div>
</section>
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
(type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{type}
</button>
),
)}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
constraint.type === "diagonal" &&
constraint.direction === direction,
);
return (
<button
key={direction}
type="button"
className={active ? "is-active" : ""}
aria-pressed={active}
onClick={() =>
onChange({
...puzzle,
constraints: active
? constraints.filter(
(constraint) =>
constraint.type !== "diagonal" ||
constraint.direction !== direction,
)
: [...constraints, { type: "diagonal", direction }],
})
}
>
{direction} diagonal
</button>
);
})}
</div>
</section>
{constraints.length > 0 && (
<section className="panel-section">
<p className="eyebrow">Constraints</p>
<ul className="constraint-list">
{constraints.map((constraint, index) => (
<li key={`${constraint.type}-${String(index)}`}>
<span>{describeConstraint(constraint, puzzle.size)}</span>
<button
className="text-button danger"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(_, item) => item !== index,
),
})
}
>
Remove
</button>
</li>
))}
</ul>
</section>
)}
<section className="panel-section action-row">
<button type="button" onClick={onCheck} disabled={busy}>
Check definition &amp; uniqueness
</button>
<button
type="button"
onClick={onGenerate}
disabled={busy || puzzle.size > 9}
>
Generate classic
</button>
</section>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { Modal } from "./Modal";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
<div className="help-grid">
<section>
<h3>Four complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and
constraints. <strong>Solve</strong> explains logical steps and can
verify uniqueness. <strong>Helpers</strong> answers focused
questions without changing the board.
</p>
</section>
<section>
<h3>Keyboard</h3>
<dl className="shortcut-list">
<div>
<dt>Arrow keys</dt>
<dd>Move the active cell</dd>
</div>
<div>
<dt>Shift + arrows</dt>
<dd>Extend the selection</dd>
</div>
<div>
<dt>19 / AG</dt>
<dd>Enter the selected symbol</dd>
</div>
<div>
<dt>Z / X / C / V</dt>
<dd>Value, corner, centre or colour mode</dd>
</div>
<div>
<dt>Backspace</dt>
<dd>Erase in the current mode</dd>
</div>
<div>
<dt>Ctrl/ + Z/Y</dt>
<dd>Undo or redo</dd>
</div>
</dl>
</section>
<section>
<h3>Hints and solutions</h3>
<p>
Candidate legality is computed independently from handwritten notes.
Logical deductions report their premises, affected houses,
placements and eliminations. Exact search is separately labelled; it
proves feasibility or uniqueness but is not presented as a human
explanation.
</p>
</section>
<section>
<h3>Import and privacy</h3>
<p>
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments and supported f-puzzles data
are decoded locally. SudokuPad short IDs need its server and are
intentionally rejected. Review an export before sharing: titles,
authors, rules, solutions and progress may be included.
</p>
</section>
</div>
</Modal>
);
}
+407
View File
@@ -0,0 +1,407 @@
import { useMemo, useState } from "react";
import {
analyzeKillerCage,
calculateResidual,
relationPairs,
type RelationSpec,
} from "../helpers";
import { maskValues, symbolFor } from "../state/session";
function digits(text: string) {
return [
...new Set(
(text.match(/\d+|[A-P]/giu) ?? []).map((token) =>
/^\d+$/u.test(token)
? Number(token)
: token.toUpperCase().charCodeAt(0) - 55,
),
),
];
}
function numbers(text: string) {
return (text.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number);
}
export function HelpersWorkspace({
size,
selectedCells,
candidateMasks,
}: {
size: number;
selectedCells: readonly number[];
candidateMasks: readonly number[];
}) {
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
"killer",
);
const [cellCount, setCellCount] = useState(2);
const [sum, setSum] = useState(10);
const [allowed, setAllowed] = useState("");
const [required, setRequired] = useState("");
const [excluded, setExcluded] = useState("");
const [allowRepeats, setAllowRepeats] = useState(false);
const [useBoardCandidates, setUseBoardCandidates] = useState(false);
const [knownParts, setKnownParts] = useState("15, 12");
const [unknownCount, setUnknownCount] = useState(2);
const [relation, setRelation] = useState("white");
const [firstCandidates, setFirstCandidates] = useState("");
const [secondCandidates, setSecondCandidates] = useState("");
const effectiveCount =
useBoardCandidates && selectedCells.length > 0
? selectedCells.length
: cellCount;
const killer = useMemo(() => {
try {
const candidates =
useBoardCandidates && selectedCells.length > 0
? selectedCells.map((cell) =>
maskValues(candidateMasks[cell] ?? 0, size),
)
: undefined;
return {
result: analyzeKillerCage({
size,
cellCount: effectiveCount,
sum,
allowRepeats,
...(allowed.trim() ? { allowedDigits: digits(allowed) } : {}),
...(required.trim() ? { requiredDigits: digits(required) } : {}),
...(excluded.trim() ? { excludedDigits: digits(excluded) } : {}),
...(candidates === undefined ? {} : { candidates }),
}),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid helper input.",
};
}
}, [
allowRepeats,
allowed,
candidateMasks,
effectiveCount,
excluded,
required,
selectedCells,
size,
sum,
useBoardCandidates,
]);
const residual = useMemo(() => {
try {
return {
result: calculateResidual({
size,
knownSums: numbers(knownParts),
unknownCount,
allowRepeats,
}),
};
} catch (error) {
return {
error:
error instanceof Error ? error.message : "Invalid residual input.",
};
}
}, [allowRepeats, knownParts, size, unknownCount]);
const pairs = useMemo(() => {
try {
const pairSpec: RelationSpec =
relation === "white"
? { type: "kropki", kind: "white" }
: relation === "black"
? { type: "kropki", kind: "black" }
: relation === "v"
? { type: "xv", total: 5 }
: relation === "x"
? { type: "xv", total: 10 }
: relation === "less"
? { type: "inequality", relation: "<" }
: { type: "inequality", relation: ">" };
return {
result: relationPairs(
pairSpec,
size,
firstCandidates.trim() ? digits(firstCandidates) : undefined,
secondCandidates.trim() ? digits(secondCandidates) : undefined,
),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid pair input.",
};
}
}, [firstCandidates, relation, secondCandidates, size]);
return (
<div className="helpers-workspace stack">
<div>
<p className="eyebrow">Focused calculators</p>
<h2>Sudoku helpers</h2>
<p className="muted">
Answer a local question without changing the puzzle. Values are
bounded to the current {size}×{size} symbol set.
</p>
</div>
<div className="subtabs" role="tablist" aria-label="Helper">
{(
[
["killer", "Killer combinations"],
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
["relations", "Pair relations"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={helper === id}
className={helper === id ? "is-active" : ""}
onClick={() => setHelper(id)}
>
{label}
</button>
))}
</div>
{helper === "killer" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Cells
<input
type="number"
min="1"
max={size}
value={cellCount}
disabled={useBoardCandidates && selectedCells.length > 0}
onChange={(event) => setCellCount(Number(event.target.value))}
/>
</label>
<label>
Sum
<input
type="number"
min="1"
max={size * size}
value={sum}
onChange={(event) => setSum(Number(event.target.value))}
/>
</label>
<label>
Allowed digits
<input
value={allowed}
placeholder="all"
onChange={(event) => setAllowed(event.target.value)}
/>
</label>
<label>
Must include
<input
value={required}
placeholder="e.g. 1, 7"
onChange={(event) => setRequired(event.target.value)}
/>
</label>
<label>
Exclude
<input
value={excluded}
placeholder="e.g. 5"
onChange={(event) => setExcluded(event.target.value)}
/>
</label>
<label className="check-row">
<input
type="checkbox"
checked={allowRepeats}
onChange={(event) => setAllowRepeats(event.target.checked)}
/>
Allow repeated digits
</label>
<label className="check-row">
<input
type="checkbox"
checked={useBoardCandidates}
disabled={selectedCells.length === 0}
onChange={(event) =>
setUseBoardCandidates(event.target.checked)
}
/>
Use {selectedCells.length || "selected"} board cell
{selectedCells.length === 1 ? "" : "s"} and candidates
</label>
</div>
{killer.error ? (
<p className="error-callout" role="alert">
{killer.error}
</p>
) : killer.result ? (
<div className="helper-result">
<div className="metric-row">
<span>
<strong>{killer.result.combinations.length}</strong>{" "}
combinations
</span>
<span>
Possible{" "}
<strong>
{killer.result.possibleDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
<span>
Necessary{" "}
<strong>
{killer.result.necessaryDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
</div>
{killer.result.possibleByCell.some(
(entry) => entry.length > 0,
) && (
<ol className="cell-possibilities">
{killer.result.possibleByCell.map((entry, index) => (
<li key={index}>
Cell {index + 1}:{" "}
{entry.map((value) => symbolFor(value, size)).join(" ") ||
"—"}
</li>
))}
</ol>
)}
<div className="combination-cloud" aria-label="Combinations">
{killer.result.combinations.slice(0, 300).map((combination) => (
<code key={combination.join("-")}>
{combination
.map((value) => symbolFor(value, size))
.join("")}
</code>
))}
</div>
{(killer.result.combinations.length > 300 ||
killer.result.truncated) && (
<p className="muted">
The visible list is bounded; refine the filters to inspect
fewer results.
</p>
)}
</div>
) : null}
</section>
)}
{helper === "residual" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Accounted values or cage sums
<input
value={knownParts}
onChange={(event) => setKnownParts(event.target.value)}
/>
</label>
<label>
Unaccounted cells
<input
type="number"
min="0"
max={size}
value={unknownCount}
onChange={(event) =>
setUnknownCount(Number(event.target.value))
}
/>
</label>
</div>
{residual.error ? (
<p className="error-callout" role="alert">
{residual.error}
</p>
) : residual.result ? (
<div className="helper-result">
<p className="residual-equation">
{residual.result.total} {residual.result.accounted} ={" "}
<strong>{residual.result.residual}</strong>
</p>
<p>
{residual.result.analysis?.combinations.length ?? 0} possible
digit combinations for {unknownCount} residual cell
{unknownCount === 1 ? "" : "s"}.
</p>
<div className="combination-cloud">
{residual.result.analysis?.combinations
.slice(0, 300)
.map((combination) => (
<code key={combination.join("-")}>
{combination
.map((value) => symbolFor(value, size))
.join("")}
</code>
))}
</div>
</div>
) : null}
</section>
)}
{helper === "relations" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Relation
<select
value={relation}
onChange={(event) => setRelation(event.target.value)}
>
<option value="white">White Kropki · difference 1</option>
<option value="black">Black Kropki · ratio 1:2</option>
<option value="v">V · sum 5</option>
<option value="x">X · sum 10</option>
<option value="less">First &lt; second</option>
<option value="greater">First &gt; second</option>
</select>
</label>
<label>
First-cell candidates
<input
value={firstCandidates}
placeholder="all"
onChange={(event) => setFirstCandidates(event.target.value)}
/>
</label>
<label>
Second-cell candidates
<input
value={secondCandidates}
placeholder="all"
onChange={(event) => setSecondCandidates(event.target.value)}
/>
</label>
</div>
{pairs.error ? (
<p className="error-callout" role="alert">
{pairs.error}
</p>
) : (
<div className="pair-table">
{pairs.result?.map(([first, second]) => (
<code key={`${String(first)}-${String(second)}`}>
{symbolFor(first, size)} {symbolFor(second, size)}
</code>
))}
</div>
)}
</section>
)}
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import { useMemo, useRef, useState } from "react";
import type { PuzzleDefinition } from "../domain/types";
import {
decodePuzzleHash,
encodePuzzleHash,
exportFpuzzlesJson,
exportFpuzzlesUrl,
fromDomainPuzzle,
importFpuzzles,
parseFpuzzles,
parsePlainGrid,
parseSudokuDocument,
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
type SudokuDocument,
} from "../formats";
import type { PlaySession } from "../state/session";
import { maskValues } from "../state/session";
import { Modal } from "./Modal";
function parseImport(input: string): SudokuDocument {
const trimmed = input.trim();
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku="))
return decodePuzzleHash(trimmed);
if (
/^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test(
trimmed,
)
)
return importFpuzzles(trimmed);
if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed);
if (trimmed.startsWith("{")) {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === "object" && parsed !== null && "schema" in parsed)
return parseSudokuDocument(trimmed);
return parseFpuzzles(parsed);
}
return parsePlainGrid(trimmed);
}
function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
): SudokuDocument {
const base = fromDomainPuzzle(puzzle);
return {
...base,
values: [...session.values],
cornerMarks: session.cornerMarks.map((mask) =>
maskValues(mask, puzzle.size),
),
centerMarks: session.centerMarks.map((mask) =>
maskValues(mask, puzzle.size),
),
colors: [...session.colors],
elapsedMs: session.elapsedSeconds * 1_000,
};
}
function download(name: string, contents: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
progress?: Pick<
SudokuDocument,
| "values"
| "cornerMarks"
| "centerMarks"
| "candidates"
| "colors"
| "elapsedMs"
>,
) => void;
}
export function ImportExportDialog({
open,
puzzle,
session,
onClose,
onImport,
}: ImportExportDialogProps) {
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo(
() =>
includeProgress
? withProgress(puzzle, session)
: fromDomainPuzzle(puzzle),
[includeProgress, puzzle, session],
);
const copy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
setFeedback(`${label} copied.`);
} catch {
setFeedback(
"Clipboard access was denied; use the download option instead.",
);
}
};
return (
<Modal open={open} title="Import and export" onClose={onClose} wide>
<div className="import-export-grid">
<section className="stack">
<div>
<p className="eyebrow">Import</p>
<h3>Paste puzzle data</h3>
</div>
<p className="muted">
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only
short IDs are never fetched.
</p>
<textarea
rows={12}
value={input}
maxLength={1_048_576}
spellCheck={false}
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => setInput(event.target.value)}
/>
<div className="action-row">
<button type="button" onClick={() => fileRef.current?.click()}>
Choose puzzle file
</button>
<input
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,text/plain,.json,.txt,.sdk"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file === undefined) return;
if (file.size > 1_048_576) {
setFeedback("Puzzle files are limited to 1 MiB.");
return;
}
void file
.text()
.then((contents) => {
setInput(contents);
setFeedback(
`${file.name} loaded locally; review and import it.`,
);
})
.catch(() =>
setFeedback("The puzzle file could not be read."),
);
}}
/>
<button
type="button"
disabled={!input.trim()}
onClick={() => {
try {
const parsed = parseImport(input);
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
});
setFeedback("Puzzle imported locally.");
onClose();
} catch (error) {
setFeedback(
error instanceof Error
? error.message
: "The puzzle could not be imported.",
);
}
}}
>
Import locally
</button>
</div>
</section>
<section className="stack">
<div>
<p className="eyebrow">Export</p>
<h3>Choose a portable representation</h3>
</div>
<label className="check-row">
<input
type="checkbox"
checked={includeProgress}
onChange={(event) => setIncludeProgress(event.target.checked)}
/>
Include values, notes, colours and elapsed time
</label>
<div className="export-actions">
<button
type="button"
onClick={() =>
download(
"sudoku-tools-puzzle.json",
serializeSudokuDocument(documentValue, true),
"application/json",
)
}
>
Download project JSON
</button>
<button
type="button"
onClick={() =>
download(
"sudoku.txt",
serializePlainGrid(
documentValue,
includeProgress ? "values" : "givens",
),
"text/plain",
)
}
>
Download grid text
</button>
<button
type="button"
onClick={() =>
download(
"sudoku.fpuzzles.json",
exportFpuzzlesJson(documentValue, true),
"application/json",
)
}
>
Download f-puzzles JSON
</button>
<button
type="button"
onClick={() =>
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
}
>
Copy f-puzzles URL
</button>
<button
type="button"
onClick={() => {
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
void copy(share, "Local share URL");
}}
>
Copy local share URL
</button>
</div>
<p className="callout">
Share links are self-contained. They may expose title, setter,
rules, solution progress and notes to anyone receiving the URL.
</p>
</section>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
</Modal>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useRef } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
function date(value: number) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function LibraryDialog({
open,
summaries,
mode,
busy,
feedback,
onClose,
onSave,
onOpen,
onDelete,
onClear,
onExport,
onImport,
}: {
open: boolean;
summaries: readonly SudokuProjectSummary[];
mode: "indexeddb" | "memory";
busy: boolean;
feedback?: string;
onClose: () => void;
onSave: () => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
return (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{mode === "indexeddb"
? "Saved in this browser profile."
: "IndexedDB is unavailable; saves last only for this open tab."}
</p>
<p className="muted">
Export important puzzles before clearing browser data.
</p>
</div>
<div className="action-row">
<button type="button" disabled={busy} onClick={onSave}>
Save current
</button>
<button
type="button"
disabled={busy || summaries.length === 0}
onClick={onExport}
>
Export library
</button>
<button
type="button"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
Import library
</button>
<input
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = "";
}}
/>
</div>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
<li key={item.id}>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</li>
))}
</ul>
)}
{summaries.length > 0 && (
<div className="danger-zone">
<button type="button" className="danger" onClick={onClear}>
Clear local library
</button>
</div>
)}
</Modal>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
interface ModalProps {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
wide?: boolean;
}
export function Modal({ open, title, children, onClose, wide }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
className={`modal${wide ? " modal--wide" : ""}`}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === dialogRef.current) onClose();
}}
>
<div className="modal__surface">
<header className="modal__header">
<h2 id={titleId}>{title}</h2>
<button className="icon-button" type="button" onClick={onClose}>
<span aria-hidden="true">×</span>
<span className="sr-only">Close</span>
</button>
</header>
<div className="modal__body">{children}</div>
</div>
</dialog>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
{ mode: "corner", label: "Corner", key: "X" },
{ mode: "center", label: "Centre", key: "C" },
{ mode: "color", label: "Colour", key: "V" },
];
export function NumberPad({
size,
mode,
onMode,
onValue,
onErase,
}: {
size: number;
mode: EntryMode;
onMode: (mode: EntryMode) => void;
onValue: (value: number) => void;
onErase: () => void;
}) {
return (
<div className="number-pad">
<div className="mode-switcher" role="group" aria-label="Entry mode">
{modes.map((item) => (
<button
key={item.mode}
type="button"
className={mode === item.mode ? "is-active" : ""}
aria-pressed={mode === item.mode}
title={`${item.label} mode (${item.key})`}
onClick={() => onMode(item.mode)}
>
{item.label}
</button>
))}
</div>
<div
className={`digit-pad${size > 9 ? " digit-pad--wide" : ""}`}
role="group"
aria-label={mode === "color" ? "Colours" : "Digits"}
>
{Array.from({ length: mode === "color" ? 8 : size }, (_, index) => {
const value = index + 1;
return (
<button
key={value}
type="button"
className={
mode === "color" ? `color-choice color-${String(value)}` : ""
}
onClick={() => onValue(value)}
>
{mode === "color" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
</>
) : (
symbolFor(value, size)
)}
</button>
);
})}
<button type="button" className="erase-key" onClick={onErase}>
Erase
</button>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import type {
ExactSolveResult,
LogicalSolveResult,
LogicalStep,
} from "../solver";
import { symbolFor } from "../state/session";
function cellName(cell: number, size: number) {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
function techniqueName(value: string) {
return value
.split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(" ");
}
function StepCard({
step,
index,
size,
active,
onSelect,
}: {
step: LogicalStep;
index: number;
size: number;
active: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={`solve-step${active ? " is-active" : ""}`}
onClick={onSelect}
>
<span className="step-number">{index + 1}</span>
<span>
<strong>{techniqueName(step.technique)}</strong>
<small>{step.explanation}</small>
{(step.placements.length > 0 || step.eliminations.length > 0) && (
<span className="step-effects">
{step.placements
.map(
(placement) =>
`${cellName(placement.cell, size)}=${symbolFor(placement.value, size)}`,
)
.join(", ")}
{step.placements.length > 0 && step.eliminations.length > 0
? " · "
: ""}
{step.eliminations
.map(
(elimination) =>
`${cellName(elimination.cell, size)} ${elimination.values.map((value) => symbolFor(value, size)).join("")}`,
)
.join(", ")}
</span>
)}
</span>
</button>
);
}
export function SolveWorkspace({
size,
busy,
logical,
exact,
error,
onLogical,
onExact,
onApplyValues,
onFocusCells,
}: {
size: number;
busy: boolean;
logical?: LogicalSolveResult;
exact?: ExactSolveResult;
error?: string;
onLogical: () => void;
onExact: () => void;
onApplyValues: (values: readonly number[]) => void;
onFocusCells: (cells: readonly number[]) => void;
}) {
const [stepSelection, setStepSelection] = useState<{
logical?: LogicalSolveResult;
index: number;
}>({ index: 0 });
const activeStep =
stepSelection.logical === logical ? stepSelection.index : 0;
useEffect(() => {
const step = logical?.steps[activeStep];
onFocusCells(step?.focusCells ?? []);
}, [activeStep, logical, onFocusCells]);
return (
<div className="solve-workspace stack">
<div>
<p className="eyebrow">Explainable analysis</p>
<h2>Solve and verify</h2>
<p className="muted">
Human logic and exact search are deliberately separate. A uniqueness
proof is not described as a human deduction.
</p>
</div>
<div className="action-row">
<button type="button" disabled={busy} onClick={onLogical}>
Build logical solve path
</button>
<button type="button" disabled={busy} onClick={onExact}>
Count solutions (up to 2)
</button>
</div>
{busy && (
<p className="status-line" role="status">
Analysing in a local worker
</p>
)}
{error && (
<p className="error-callout" role="alert">
{error}
</p>
)}
{exact && (
<section className="analysis-summary">
<p className="eyebrow">Exact search</p>
<div className="metric-row">
<span>
<strong>{exact.count}</strong> solution
{exact.count === 1 ? "" : "s"} found
</span>
<span>
<strong>{exact.nodes.toLocaleString()}</strong> nodes
</span>
<span>
<strong>{exact.elapsedMs.toLocaleString()}</strong> ms
</span>
</div>
<p>
{exact.count === 0
? "No completion satisfies every supported rule."
: exact.count === 1 && !exact.truncated
? "The current puzzle has exactly one solution within the configured search bounds."
: exact.count >= 2
? "At least two solutions exist; the puzzle is not unique."
: "Search stopped at a safety limit, so uniqueness is not established."}
</p>
{exact.solutions[0] && (
<button
type="button"
onClick={() => onApplyValues(exact.solutions[0]!)}
>
Show first exact solution on board
</button>
)}
</section>
)}
{logical && (
<section className="logical-results">
<div className="section-heading">
<div>
<p className="eyebrow">Logical path</p>
<h3>
{logical.steps.length} explained step
{logical.steps.length === 1 ? "" : "s"}
</h3>
</div>
<span className={`status-pill status-${logical.status}`}>
{logical.status}
</span>
</div>
<p>
{logical.status === "solved"
? "The configured human techniques complete this puzzle."
: logical.status === "stuck"
? "No supported next deduction was found. Exact search may still solve it."
: logical.status === "invalid"
? "A contradiction was reached."
: "The logical-step safety limit was reached."}
</p>
{logical.status === "solved" && (
<button type="button" onClick={() => onApplyValues(logical.values)}>
Show logical result on board
</button>
)}
<div className="solve-steps">
{logical.steps.map((step, index) => (
<StepCard
key={`${step.technique}-${String(index)}`}
step={step}
index={index}
size={size}
active={activeStep === index}
onSelect={() => setStepSelection({ logical, index })}
/>
))}
</div>
</section>
)}
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
import { maskValues, symbolFor } from "../state/session";
interface SudokuBoardProps {
puzzle: NormalizedPuzzle;
values: readonly number[];
cornerMarks?: readonly number[];
centerMarks?: readonly number[];
colors?: readonly number[];
candidates?: readonly number[];
selected: ReadonlySet<number>;
conflicts?: ReadonlySet<number>;
activeCell: number;
showCandidates?: boolean;
onCellPointerDown: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
) => void;
onCellPointerEnter: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
) => void;
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
}
function point(size: number, cell: number) {
return {
x: (cell % size) + 0.5,
y: Math.floor(cell / size) + 0.5,
};
}
function polyline(size: number, cells: readonly number[]) {
return cells
.map((cell) => {
const { x, y } = point(size, cell);
return `${x},${y}`;
})
.join(" ");
}
function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
const commands: string[] = [];
for (const cell of cells) {
const row = Math.floor(cell / size);
const column = cell % size;
const x0 = column + inset;
const x1 = column + 1 - inset;
const y0 = row + inset;
const y1 = row + 1 - inset;
if (!cells.has(cell - size) || row === 0)
commands.push(`M${x0} ${y0}H${x1}`);
if (!cells.has(cell + size) || row === size - 1)
commands.push(`M${x0} ${y1}H${x1}`);
if (!cells.has(cell - 1) || column === 0)
commands.push(`M${x0} ${y0}V${y1}`);
if (!cells.has(cell + 1) || column === size - 1)
commands.push(`M${x1} ${y0}V${y1}`);
}
return commands.join(" ");
}
function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const { size } = puzzle;
const regionCells = Array.from({ length: size }, () => new Set<number>());
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
const renderConstraint = (constraint: VariantConstraint, index: number) => {
if (constraint.type === "diagonal") {
return (
<line
key={index}
className="constraint-diagonal"
x1={constraint.direction === "main" ? 0.12 : size - 0.12}
y1={0.12}
x2={constraint.direction === "main" ? size - 0.12 : 0.12}
y2={size - 0.12}
/>
);
}
if (constraint.type === "killer-cage") {
const cells = new Set(constraint.cells);
const first = Math.min(...constraint.cells);
const position = point(size, first);
return (
<g key={index} className="constraint-cage">
<path d={boundaryPath(size, cells, 0.09)} />
<text x={position.x - 0.34} y={position.y - 0.27}>
{constraint.sum}
</text>
</g>
);
}
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!);
return (
<g key={index} className="constraint-thermo">
<polyline points={polyline(size, constraint.cells)} />
<circle cx={bulb.x} cy={bulb.y} r="0.31" />
</g>
);
}
if (constraint.type === "arrow") {
const bulb = point(size, constraint.bulb[0]!);
const end = point(size, constraint.line.at(-1)!);
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
return (
<g key={index} className="constraint-arrow">
<circle cx={bulb.x} cy={bulb.y} r="0.32" />
<polyline points={polyline(size, connectedLine)} />
<circle cx={end.x} cy={end.y} r="0.08" />
</g>
);
}
if (constraint.type === "renban" || constraint.type === "palindrome") {
return (
<g key={index} className={`constraint-${constraint.type}`}>
<polyline points={polyline(size, constraint.cells)} />
{constraint.type === "palindrome" &&
constraint.cells.map((cell) => {
const p = point(size, cell);
return <circle key={cell} cx={p.x} cy={p.y} r="0.14" />;
})}
</g>
);
}
if (
constraint.type === "kropki" ||
constraint.type === "xv" ||
constraint.type === "inequality"
) {
const a = point(
size,
constraint.type === "inequality" ? constraint.lesser : constraint.a,
);
const b = point(
size,
constraint.type === "inequality" ? constraint.greater : constraint.b,
);
const x = (a.x + b.x) / 2;
const y = (a.y + b.y) / 2;
if (constraint.type === "kropki")
return (
<circle
key={index}
className={`constraint-kropki constraint-kropki--${constraint.kind}`}
cx={x}
cy={y}
r="0.115"
/>
);
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
return (
<text
key={index}
className={`constraint-label constraint-label--${constraint.type}`}
x={x}
y={y}
transform={
constraint.type === "inequality"
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
: undefined
}
>
{constraint.type === "xv"
? constraint.total === 5
? "V"
: "X"
: "<"}
</text>
);
}
return null;
};
return (
<svg
className="constraint-layer"
viewBox={`0 0 ${String(size)} ${String(size)}`}
aria-hidden="true"
>
<g className="region-boundaries">
{regionCells.map((cells, index) => (
<path key={index} d={boundaryPath(size, cells, 0)} />
))}
</g>
{puzzle.constraints.map(renderConstraint)}
</svg>
);
}
export function SudokuBoard({
puzzle,
values,
cornerMarks = [],
centerMarks = [],
colors = [],
candidates = [],
selected,
conflicts = new Set<number>(),
activeCell,
showCandidates,
onCellPointerDown,
onCellPointerEnter,
onKeyDown,
}: SudokuBoardProps) {
const { size } = puzzle;
return (
<div
className="sudoku-board-frame"
style={{ "--sudoku-size": size } as CSSProperties}
>
<div
className="sudoku-board"
role="grid"
aria-label={`${String(size)} by ${String(size)} Sudoku grid`}
onKeyDown={onKeyDown}
>
{values.map((value, cell) => {
const isGiven = Boolean(puzzle.givens[cell]);
const corner = maskValues(cornerMarks[cell] ?? 0, size);
const center = maskValues(
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0),
size,
);
const row = Math.floor(cell / size) + 1;
const column = (cell % size) + 1;
return (
<button
key={cell}
type="button"
role="gridcell"
aria-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
aria-selected={selected.has(cell)}
tabIndex={cell === activeCell ? 0 : -1}
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
selected.has(cell) ? "is-selected" : "",
conflicts.has(cell) ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
]
.filter(Boolean)
.join(" ")}
onPointerDown={(event) => onCellPointerDown(cell, event)}
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
data-cell={cell}
>
{value ? (
<span className="cell-value">{symbolFor(value, size)}</span>
) : (
<>
<span className="corner-marks" aria-hidden="true">
{corner.map((mark) => (
<span key={mark}>{symbolFor(mark, size)}</span>
))}
</span>
<span className="center-marks" aria-hidden="true">
{center.map((mark) => symbolFor(mark, size)).join("")}
</span>
</>
)}
</button>
);
})}
</div>
<ConstraintLayer puzzle={puzzle} />
</div>
);
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import type { PuzzleDefinition } from "../domain/types";
const classicGrid =
"460000001000300476009060050804000607000854000102000805030040900928001000500000013";
export const CLASSIC_SAMPLE: PuzzleDefinition = {
version: 1,
size: 9,
givens: [...classicGrid].map(Number),
title: "A first classic",
author: "Sudoku Tools",
rules: "Place 19 exactly once in every row, column and outlined 3×3 region.",
};
export const KILLER_SAMPLE: PuzzleDefinition = {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
title: "Pocket Killer",
author: "Sudoku Tools",
rules:
"Place 14 once per row, column and 2×2 region. Digits in a dashed cage sum to its clue and do not repeat.",
constraints: [
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "killer-cage", cells: [2, 3, 7], sum: 9 },
{ type: "killer-cage", cells: [4, 8], sum: 4 },
{ type: "killer-cage", cells: [5, 6], sum: 5 },
{ type: "killer-cage", cells: [9, 10], sum: 5 },
{ type: "killer-cage", cells: [11, 15], sum: 5 },
{ type: "killer-cage", cells: [12, 13], sum: 6 },
{ type: "killer-cage", cells: [14], sum: 3 },
],
};
export const SAMPLE_PUZZLES = [CLASSIC_SAMPLE, KILLER_SAMPLE] as const;
+174
View File
@@ -0,0 +1,174 @@
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
import type { CellId, NormalizedPuzzle, VariantConstraint } from "./types";
export type UnitKind = "row" | "column" | "region" | "diagonal";
export interface SudokuUnit {
readonly kind: UnitKind;
readonly index: number;
readonly cells: readonly CellId[];
}
export interface CompiledPuzzle {
readonly puzzle: NormalizedPuzzle;
readonly units: readonly SudokuUnit[];
readonly unitsByCell: readonly (readonly number[])[];
/** Cells which may not contain an equal value. */
readonly peers: readonly ReadonlySet<CellId>[];
readonly constraintsByCell: readonly (readonly number[])[];
readonly orthogonalByCell: readonly (readonly CellId[])[];
}
function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
if (a === b) return;
peers[a]?.add(b);
peers[b]?.add(a);
}
function cellsForConstraint(
size: number,
constraint: VariantConstraint,
): readonly CellId[] {
switch (constraint.type) {
case "diagonal":
return Array.from({ length: size }, (_, index) =>
constraint.direction === "main"
? index * size + index
: index * size + size - index - 1,
);
case "anti-knight":
case "anti-king":
case "non-consecutive":
return Array.from({ length: size * size }, (_, cell) => cell);
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
return constraint.cells;
case "arrow":
return [...constraint.bulb, ...constraint.line];
case "kropki":
case "xv":
return [constraint.a, constraint.b];
case "inequality":
return [constraint.lesser, constraint.greater];
}
}
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const { size } = puzzle;
const count = size * size;
const units: SudokuUnit[] = [];
for (let index = 0; index < size; index += 1) {
units.push({
kind: "row",
index,
cells: Array.from({ length: size }, (_, column) => index * size + column),
});
units.push({
kind: "column",
index,
cells: Array.from({ length: size }, (_, row) => row * size + index),
});
units.push({
kind: "region",
index,
cells: Array.from({ length: count }, (_, cell) => cell).filter(
(cell) => puzzle.regions[cell] === index,
),
});
}
for (const constraint of puzzle.constraints) {
if (constraint.type !== "diagonal") continue;
units.push({
kind: "diagonal",
index: constraint.direction === "main" ? 0 : 1,
cells: cellsForConstraint(size, constraint),
});
}
const peers = Array.from({ length: count }, () => new Set<CellId>());
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
units.forEach((unit, unitIndex) => {
for (const cell of unit.cells) {
unitsByCell[cell]?.push(unitIndex);
for (const other of unit.cells) addPeerPair(peers, cell, other);
}
});
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
puzzle.constraints.forEach((constraint, constraintIndex) => {
for (const cell of new Set(cellsForConstraint(size, constraint))) {
constraintsByCell[cell]?.push(constraintIndex);
}
if (constraint.type === "killer-cage" && constraint.noRepeat !== false) {
for (const a of constraint.cells) {
for (const b of constraint.cells) addPeerPair(peers, a, b);
}
}
});
const hasAntiKnight = puzzle.constraints.some(
({ type }) => type === "anti-knight",
);
const hasAntiKing = puzzle.constraints.some(
({ type }) => type === "anti-king",
);
if (hasAntiKnight || hasAntiKing) {
for (let cell = 0; cell < count; cell += 1) {
const row = cellRow(size, cell);
const column = cellColumn(size, cell);
if (hasAntiKnight) {
for (const [dr, dc] of [
[-2, -1],
[-2, 1],
[-1, -2],
[-1, 2],
[1, -2],
[1, 2],
[2, -1],
[2, 1],
] as const) {
const otherRow = row + dr;
const otherColumn = column + dc;
if (
otherRow >= 0 &&
otherRow < size &&
otherColumn >= 0 &&
otherColumn < size
) {
addPeerPair(peers, cell, otherRow * size + otherColumn);
}
}
}
if (hasAntiKing) {
for (let dr = -1; dr <= 1; dr += 1) {
for (let dc = -1; dc <= 1; dc += 1) {
if (dr === 0 && dc === 0) continue;
const otherRow = row + dr;
const otherColumn = column + dc;
if (
otherRow >= 0 &&
otherRow < size &&
otherColumn >= 0 &&
otherColumn < size
) {
addPeerPair(peers, cell, otherRow * size + otherColumn);
}
}
}
}
}
}
return {
puzzle,
units,
unitsByCell,
peers,
constraintsByCell,
orthogonalByCell: Array.from({ length: count }, (_, cell) =>
orthogonalNeighbours(size, cell),
),
};
}
+128
View File
@@ -0,0 +1,128 @@
import {
EMPTY_VALUE,
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
type CellId,
type PuzzleDefinition,
} from "./types";
function assertSize(size: number): void {
if (
!Number.isInteger(size) ||
size < MIN_PUZZLE_SIZE ||
size > MAX_PUZZLE_SIZE
) {
throw new RangeError(
`Puzzle size must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}.`,
);
}
}
export function cellId(size: number, row: number, column: number): CellId {
assertSize(size);
if (!Number.isInteger(row) || row < 0 || row >= size) {
throw new RangeError("Row is outside the grid.");
}
if (!Number.isInteger(column) || column < 0 || column >= size) {
throw new RangeError("Column is outside the grid.");
}
return row * size + column;
}
export function cellRow(size: number, cell: CellId): number {
assertCell(size, cell);
return Math.floor(cell / size);
}
export function cellColumn(size: number, cell: CellId): number {
assertCell(size, cell);
return cell % size;
}
export function assertCell(size: number, cell: CellId): void {
assertSize(size);
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) {
throw new RangeError(`Cell ${String(cell)} is outside the grid.`);
}
}
/**
* Builds conventional rectangular regions. For non-composite sizes this falls
* back to 1 x size regions, which is still a valid Latin-square topology.
*/
export function classicRegions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
assertSize(size);
let rows = boxRows;
let columns = boxColumns;
if (rows === undefined && columns === undefined) {
rows = Math.floor(Math.sqrt(size));
while (rows > 1 && size % rows !== 0) rows -= 1;
columns = size / rows;
} else if (
rows === undefined &&
columns !== undefined &&
size % columns === 0
) {
rows = size / columns;
} else if (columns === undefined && rows !== undefined && size % rows === 0) {
columns = size / rows;
}
if (
!Number.isInteger(rows) ||
!Number.isInteger(columns) ||
rows === undefined ||
columns === undefined ||
rows <= 0 ||
columns <= 0 ||
rows * columns !== size
) {
throw new RangeError(
"Box rows and columns must be positive factors whose product is size.",
);
}
const regions = new Array<number>(size * size);
const boxesPerRow = size / columns;
for (let row = 0; row < size; row += 1) {
for (let column = 0; column < size; column += 1) {
regions[row * size + column] =
Math.floor(row / rows) * boxesPerRow + Math.floor(column / columns);
}
}
return regions;
}
export function createEmptyPuzzle(
size = 9,
options: {
readonly boxRows?: number;
readonly boxColumns?: number;
readonly title?: string;
readonly author?: string;
} = {},
): PuzzleDefinition {
const puzzle: PuzzleDefinition = {
version: 1,
size,
givens: new Array<number>(size * size).fill(EMPTY_VALUE),
regions: classicRegions(size, options.boxRows, options.boxColumns),
constraints: [],
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
return puzzle;
}
export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
const row = cellRow(size, cell);
const column = cellColumn(size, cell);
const result: CellId[] = [];
if (row > 0) result.push(cell - size);
if (row + 1 < size) result.push(cell + size);
if (column > 0) result.push(cell - 1);
if (column + 1 < size) result.push(cell + 1);
return result;
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./compile";
export * from "./geometry";
export * from "./rules";
export * from "./types";
export * from "./validation";
+342
View File
@@ -0,0 +1,342 @@
import { compilePuzzle, type CompiledPuzzle } from "./compile";
import type {
CellId,
NormalizedPuzzle,
PuzzleConflict,
PuzzleDefinition,
VariantConstraint,
} from "./types";
import { normalizePuzzle } from "./validation";
function valuesAt(
values: readonly number[],
cells: readonly CellId[],
): number[] {
return cells.map((cell) => values[cell] ?? 0);
}
function sumRange(
assigned: readonly number[],
blanks: number,
size: number,
noRepeat: boolean,
): readonly [number, number] {
if (!noRepeat) {
const current = assigned.reduce((sum, value) => sum + value, 0);
return [current + blanks, current + blanks * size];
}
const used = new Set(assigned);
const available = Array.from(
{ length: size },
(_, index) => index + 1,
).filter((value) => !used.has(value));
if (available.length < blanks)
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
const current = assigned.reduce((sum, value) => sum + value, 0);
const low = available
.slice(0, blanks)
.reduce((sum, value) => sum + value, current);
const high = available
.slice(-blanks)
.reduce((sum, value) => sum + value, current);
return [low, high];
}
function sumsCanMeet(
values: readonly number[],
cells: readonly CellId[],
size: number,
): readonly [number, number] {
let sum = 0;
let blanks = 0;
for (const cell of cells) {
const value = values[cell] ?? 0;
if (value === 0) blanks += 1;
else sum += value;
}
return [sum + blanks, sum + blanks * size];
}
export function constraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
): boolean {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
return true; // Equality conflicts are represented in compiled peers/units.
case "non-consecutive": {
for (let cell = 0; cell < size * size; cell += 1) {
const value = values[cell] ?? 0;
if (value === 0) continue;
const row = Math.floor(cell / size);
const column = cell % size;
if (column + 1 < size) {
const right = values[cell + 1] ?? 0;
if (right !== 0 && Math.abs(value - right) === 1) return false;
}
if (row + 1 < size) {
const below = values[cell + size] ?? 0;
if (below !== 0 && Math.abs(value - below) === 1) return false;
}
}
return true;
}
case "killer-cage": {
const cageValues = valuesAt(values, constraint.cells);
const assigned = cageValues.filter((value) => value !== 0);
if (
constraint.noRepeat !== false &&
new Set(assigned).size !== assigned.length
)
return false;
const [low, high] = sumRange(
assigned,
cageValues.length - assigned.length,
size,
constraint.noRepeat !== false,
);
return constraint.sum >= low && constraint.sum <= high;
}
case "thermo": {
const length = constraint.cells.length;
const fixed: Array<readonly [number, number]> = [];
for (let index = 0; index < length; index += 1) {
const value = values[constraint.cells[index] ?? -1] ?? 0;
if (value === 0) continue;
if (value < index + 1 || value > size - length + index + 1)
return false;
fixed.push([index, value]);
}
for (let left = 0; left < fixed.length; left += 1) {
for (let right = left + 1; right < fixed.length; right += 1) {
const a = fixed[left];
const b = fixed[right];
if (a === undefined || b === undefined || b[1] - a[1] < b[0] - a[0])
return false;
}
}
return true;
}
case "arrow": {
const [bulbLow, bulbHigh] = sumsCanMeet(values, constraint.bulb, size);
const [lineLow, lineHigh] = sumsCanMeet(values, constraint.line, size);
return bulbLow <= lineHigh && lineLow <= bulbHigh;
}
case "kropki": {
const a = values[constraint.a] ?? 0;
const b = values[constraint.b] ?? 0;
if (a === 0 || b === 0) return true;
return constraint.kind === "white"
? Math.abs(a - b) === 1
: a === b * 2 || b === a * 2;
}
case "xv": {
const a = values[constraint.a] ?? 0;
const b = values[constraint.b] ?? 0;
if (a === 0 || b === 0) return true;
return a + b === constraint.total;
}
case "inequality": {
const lesser = values[constraint.lesser] ?? 0;
const greater = values[constraint.greater] ?? 0;
return lesser === 0 || greater === 0 || lesser < greater;
}
case "renban": {
const assigned = valuesAt(values, constraint.cells).filter(
(value) => value !== 0,
);
if (new Set(assigned).size !== assigned.length) return false;
if (assigned.length <= 1) return true;
const low = Math.min(...assigned);
const high = Math.max(...assigned);
return high - low < constraint.cells.length;
}
case "palindrome": {
for (
let index = 0;
index < Math.floor(constraint.cells.length / 2);
index += 1
) {
const a = values[constraint.cells[index] ?? -1] ?? 0;
const b =
values[constraint.cells[constraint.cells.length - index - 1] ?? -1] ??
0;
if (a !== 0 && b !== 0 && a !== b) return false;
}
return true;
}
}
}
function asCompiled(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
): CompiledPuzzle {
if ("puzzle" in puzzle && "peers" in puzzle) return puzzle;
return compilePuzzle(normalizePuzzle(puzzle));
}
export function canPlaceValue(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
cell: CellId,
value: number,
): boolean {
const compiled = asCompiled(puzzle);
const size = compiled.puzzle.size;
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) return false;
if (!Number.isInteger(value) || value < 1 || value > size) return false;
for (const peer of compiled.peers[cell] ?? []) {
if (values[peer] === value) return false;
}
const next = values.slice();
next[cell] = value;
for (const index of compiled.constraintsByCell[cell] ?? []) {
const constraint = compiled.puzzle.constraints[index];
if (
constraint !== undefined &&
!constraintIsFeasible(constraint, next, size)
)
return false;
}
return true;
}
export function candidatesForCell(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
cell: CellId,
): number[] {
const compiled = asCompiled(puzzle);
if ((values[cell] ?? 0) !== 0) return [];
const result: number[] = [];
for (let value = 1; value <= compiled.puzzle.size; value += 1) {
if (canPlaceValue(compiled, values, cell, value)) result.push(value);
}
return result;
}
export function allCandidates(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
): readonly (readonly number[])[] {
const compiled = asCompiled(puzzle);
return values.map((value, cell) =>
value === 0 ? candidatesForCell(compiled, values, cell) : [],
);
}
export function findConflicts(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values?: readonly number[],
): PuzzleConflict[] {
const compiled = asCompiled(puzzle);
const board = values ?? compiled.puzzle.givens;
const conflicts: PuzzleConflict[] = [];
if (board.length !== compiled.puzzle.size * compiled.puzzle.size) {
return [
{
kind: "invalid-value",
cells: [],
message: "Board length does not match the grid.",
},
];
}
board.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > compiled.puzzle.size) {
conflicts.push({
kind: "invalid-value",
cells: [cell],
message: "Value is outside the grid range.",
});
}
});
compiled.units.forEach((unit) => {
const byValue = new Map<number, CellId[]>();
for (const cell of unit.cells) {
const value = board[cell] ?? 0;
if (value === 0) continue;
const cells = byValue.get(value) ?? [];
cells.push(cell);
byValue.set(value, cells);
}
for (const [value, cells] of byValue) {
if (cells.length > 1) {
conflicts.push({
kind: "duplicate",
cells,
message: `Value ${value} is repeated in ${unit.kind} ${unit.index + 1}.`,
});
}
}
});
// Peer relationships not already represented by a unit (anti chess and cage uniqueness).
const unitPairs = new Set<string>();
for (const unit of compiled.units) {
for (const a of unit.cells)
for (const b of unit.cells)
unitPairs.add(a < b ? `${a}:${b}` : `${b}:${a}`);
}
compiled.peers.forEach((peers, a) => {
for (const b of peers) {
if (a >= b || unitPairs.has(`${a}:${b}`)) continue;
const value = board[a] ?? 0;
if (value !== 0 && value === board[b]) {
conflicts.push({
kind: "duplicate",
cells: [a, b],
message: "Equal values conflict.",
});
}
}
});
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
const cells = (() => {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "non-consecutive":
return Array.from(
{ length: compiled.puzzle.size * compiled.puzzle.size },
(_, cell) => cell,
);
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
return constraint.cells;
case "arrow":
return [...constraint.bulb, ...constraint.line];
case "kropki":
case "xv":
return [constraint.a, constraint.b];
case "inequality":
return [constraint.lesser, constraint.greater];
}
})();
conflicts.push({
kind: "constraint",
cells,
message: `${constraint.type} constraint cannot be satisfied.`,
constraintIndex,
});
}
});
return conflicts;
}
export function isSolved(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
): boolean {
const compiled = asCompiled(puzzle);
return (
values.length === compiled.puzzle.size * compiled.puzzle.size &&
values.every((value) => value !== 0) &&
findConflicts(compiled, values).length === 0
);
}
+141
View File
@@ -0,0 +1,141 @@
export const MIN_PUZZLE_SIZE = 4;
export const MAX_PUZZLE_SIZE = 16;
export const EMPTY_VALUE = 0;
/** A zero-based cell index: `row * size + column`. */
export type CellId = number;
export type CellValue = number;
export interface DiagonalConstraint {
readonly type: "diagonal";
readonly direction: "main" | "anti";
}
export interface AntiKnightConstraint {
readonly type: "anti-knight";
}
export interface AntiKingConstraint {
readonly type: "anti-king";
}
export interface NonConsecutiveConstraint {
readonly type: "non-consecutive";
}
export interface KillerCageConstraint {
readonly type: "killer-cage";
readonly cells: readonly CellId[];
readonly sum: number;
/** Killer cages normally do not repeat digits. Defaults to true. */
readonly noRepeat?: boolean;
}
export interface ThermoConstraint {
readonly type: "thermo";
/** Ordered from bulb to tip. Values must increase strictly. */
readonly cells: readonly CellId[];
}
export interface ArrowConstraint {
readonly type: "arrow";
/** Bulb cells. Their values sum to the values on the line. */
readonly bulb: readonly CellId[];
readonly line: readonly CellId[];
}
export interface KropkiConstraint {
readonly type: "kropki";
readonly a: CellId;
readonly b: CellId;
/** White means consecutive; black means a 1:2 ratio. */
readonly kind: "white" | "black";
}
export interface XvConstraint {
readonly type: "xv";
readonly a: CellId;
readonly b: CellId;
readonly total: 5 | 10;
}
export interface InequalityConstraint {
readonly type: "inequality";
readonly lesser: CellId;
readonly greater: CellId;
}
export interface RenbanConstraint {
readonly type: "renban";
readonly cells: readonly CellId[];
}
export interface PalindromeConstraint {
readonly type: "palindrome";
readonly cells: readonly CellId[];
}
export type VariantConstraint =
| DiagonalConstraint
| AntiKnightConstraint
| AntiKingConstraint
| NonConsecutiveConstraint
| KillerCageConstraint
| ThermoConstraint
| ArrowConstraint
| KropkiConstraint
| XvConstraint
| InequalityConstraint
| RenbanConstraint
| PalindromeConstraint;
export interface PuzzleDefinition {
readonly version: 1;
readonly size: number;
/** Row-major values. Zero denotes an empty cell. */
readonly givens: readonly CellValue[];
/** Row-major region IDs in the range 0..size-1. Omit for rectangular boxes. */
readonly regions?: readonly number[];
readonly constraints?: readonly VariantConstraint[];
readonly id?: string;
readonly title?: string;
readonly author?: string;
readonly rules?: string;
/** Optional trusted solution, stored row-major without zeroes. */
readonly solution?: readonly CellValue[];
}
export interface NormalizedPuzzle extends PuzzleDefinition {
readonly regions: readonly number[];
readonly constraints: readonly VariantConstraint[];
}
export type ConflictKind =
"duplicate" | "constraint" | "impossible" | "invalid-value";
export interface PuzzleConflict {
readonly kind: ConflictKind;
readonly cells: readonly CellId[];
readonly message: string;
readonly constraintIndex?: number;
}
export interface ValidationIssue {
readonly path: string;
readonly message: string;
}
export interface ValidationResult {
readonly valid: boolean;
readonly issues: readonly ValidationIssue[];
}
export class PuzzleValidationError extends Error {
readonly issues: readonly ValidationIssue[];
constructor(issues: readonly ValidationIssue[]) {
super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
this.name = "PuzzleValidationError";
this.issues = issues;
}
}
+511
View File
@@ -0,0 +1,511 @@
import { classicRegions } from "./geometry";
import { compilePuzzle } from "./compile";
import { findConflicts } from "./rules";
import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
PuzzleValidationError,
type NormalizedPuzzle,
type PuzzleDefinition,
type ValidationIssue,
type ValidationResult,
type VariantConstraint,
} from "./types";
const MAX_CONSTRAINTS = 4_096;
const MAX_SHORT_TEXT = 256;
const MAX_RULES_TEXT = 16_384;
const ROOT_KEYS = new Set([
"version",
"size",
"givens",
"regions",
"constraints",
"id",
"title",
"author",
"rules",
"solution",
]);
const CONSTRAINT_KEYS: Readonly<
Record<VariantConstraint["type"], ReadonlySet<string>>
> = {
diagonal: new Set(["type", "direction"]),
"anti-knight": new Set(["type"]),
"anti-king": new Set(["type"]),
"non-consecutive": new Set(["type"]),
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]),
thermo: new Set(["type", "cells"]),
arrow: new Set(["type", "bulb", "line"]),
kropki: new Set(["type", "a", "b", "kind"]),
xv: new Set(["type", "a", "b", "total"]),
inequality: new Set(["type", "lesser", "greater"]),
renban: new Set(["type", "cells"]),
palindrome: new Set(["type", "cells"]),
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function add(issues: ValidationIssue[], path: string, message: string): void {
issues.push({ path, message });
}
function validateText(
value: unknown,
path: string,
maximum: number,
issues: ValidationIssue[],
): void {
if (value === undefined) return;
if (typeof value !== "string") add(issues, path, "must be a string");
else if (value.length > maximum)
add(issues, path, `must contain at most ${maximum} characters`);
}
function validateCell(
value: unknown,
path: string,
cellCount: number,
issues: ValidationIssue[],
): value is number {
if (
!Number.isInteger(value) ||
(value as number) < 0 ||
(value as number) >= cellCount
) {
add(issues, path, `must be an integer from 0 to ${cellCount - 1}`);
return false;
}
return true;
}
function validateCells(
value: unknown,
path: string,
cellCount: number,
minimum: number,
maximum: number,
issues: ValidationIssue[],
): value is readonly number[] {
if (!Array.isArray(value)) {
add(issues, path, "must be an array");
return false;
}
if (value.length < minimum || value.length > maximum) {
add(issues, path, `must contain ${minimum} to ${maximum} cells`);
}
const seen = new Set<number>();
value.forEach((cell, index) => {
if (validateCell(cell, `${path}[${index}]`, cellCount, issues)) {
if (seen.has(cell))
add(issues, `${path}[${index}]`, "must not repeat a cell");
seen.add(cell);
}
});
return true;
}
function validateConstraint(
value: unknown,
index: number,
size: number,
issues: ValidationIssue[],
): void {
const path = `constraints[${index}]`;
if (!isRecord(value) || typeof value.type !== "string") {
add(issues, path, "must be a constraint object with a type");
return;
}
const type = value.type as VariantConstraint["type"];
const allowed = CONSTRAINT_KEYS[type];
if (allowed === undefined) {
add(issues, `${path}.type`, "is not a supported constraint type");
return;
}
for (const key of Object.keys(value)) {
if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field");
}
const cellCount = size * size;
switch (type) {
case "diagonal":
if (value.direction !== "main" && value.direction !== "anti") {
add(issues, `${path}.direction`, 'must be "main" or "anti"');
}
break;
case "anti-knight":
case "anti-king":
case "non-consecutive":
break;
case "killer-cage": {
const cellsValid = validateCells(
value.cells,
`${path}.cells`,
cellCount,
1,
size,
issues,
);
if (
typeof value.noRepeat !== "undefined" &&
typeof value.noRepeat !== "boolean"
) {
add(issues, `${path}.noRepeat`, "must be a boolean");
}
if (!Number.isInteger(value.sum)) {
add(issues, `${path}.sum`, "must be an integer");
} else if (cellsValid) {
const cells = value.cells as readonly number[];
const length = cells.length;
const noRepeat = value.noRepeat !== false;
const minimum = noRepeat ? (length * (length + 1)) / 2 : length;
const maximum = noRepeat
? (length * (2 * size - length + 1)) / 2
: length * size;
if (
(value.sum as number) < minimum ||
(value.sum as number) > maximum
) {
add(
issues,
`${path}.sum`,
`must be reachable (${minimum} to ${maximum})`,
);
}
}
break;
}
case "thermo":
validateCells(value.cells, `${path}.cells`, cellCount, 2, size, issues);
break;
case "arrow": {
const bulbValid = validateCells(
value.bulb,
`${path}.bulb`,
cellCount,
1,
size,
issues,
);
const lineValid = validateCells(
value.line,
`${path}.line`,
cellCount,
1,
cellCount,
issues,
);
if (bulbValid && lineValid) {
const bulb = new Set(value.bulb as readonly number[]);
if ((value.line as readonly number[]).some((cell) => bulb.has(cell))) {
add(issues, path, "bulb and line cells must be disjoint");
}
}
break;
}
case "kropki":
validateCell(value.a, `${path}.a`, cellCount, issues);
validateCell(value.b, `${path}.b`, cellCount, issues);
if (value.a === value.b)
add(issues, path, "endpoints must be different cells");
if (value.kind !== "white" && value.kind !== "black") {
add(issues, `${path}.kind`, 'must be "white" or "black"');
}
break;
case "xv":
validateCell(value.a, `${path}.a`, cellCount, issues);
validateCell(value.b, `${path}.b`, cellCount, issues);
if (value.a === value.b)
add(issues, path, "endpoints must be different cells");
if (value.total !== 5 && value.total !== 10)
add(issues, `${path}.total`, "must be 5 or 10");
break;
case "inequality":
validateCell(value.lesser, `${path}.lesser`, cellCount, issues);
validateCell(value.greater, `${path}.greater`, cellCount, issues);
if (value.lesser === value.greater)
add(issues, path, "endpoints must be different cells");
break;
case "renban":
validateCells(value.cells, `${path}.cells`, cellCount, 2, size, issues);
break;
case "palindrome":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
2,
cellCount,
issues,
);
break;
}
}
function validateValueArray(
value: unknown,
path: string,
size: number,
allowEmpty: boolean,
issues: ValidationIssue[],
): value is readonly number[] {
if (!Array.isArray(value)) {
add(issues, path, "must be an array");
return false;
}
if (value.length !== size * size)
add(issues, path, `must contain exactly ${size * size} values`);
value.forEach((entry, index) => {
const minimum = allowEmpty ? 0 : 1;
if (!Number.isInteger(entry) || entry < minimum || entry > size) {
add(
issues,
`${path}[${index}]`,
`must be an integer from ${minimum} to ${size}`,
);
}
});
return true;
}
function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
switch (constraint.type) {
case "diagonal":
return { type: constraint.type, direction: constraint.direction };
case "anti-knight":
case "anti-king":
case "non-consecutive":
return { type: constraint.type };
case "killer-cage":
return {
type: constraint.type,
cells: [...constraint.cells],
sum: constraint.sum,
...(constraint.noRepeat === undefined
? {}
: { noRepeat: constraint.noRepeat }),
};
case "thermo":
case "renban":
case "palindrome":
return { type: constraint.type, cells: [...constraint.cells] };
case "arrow":
return {
type: constraint.type,
bulb: [...constraint.bulb],
line: [...constraint.line],
};
case "kropki":
return {
type: constraint.type,
a: constraint.a,
b: constraint.b,
kind: constraint.kind,
};
case "xv":
return {
type: constraint.type,
a: constraint.a,
b: constraint.b,
total: constraint.total,
};
case "inequality":
return {
type: constraint.type,
lesser: constraint.lesser,
greater: constraint.greater,
};
}
}
function normalizedUnchecked(puzzle: PuzzleDefinition): NormalizedPuzzle {
return {
version: 1,
size: puzzle.size,
givens: [...puzzle.givens],
regions:
puzzle.regions === undefined
? classicRegions(puzzle.size)
: [...puzzle.regions],
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
...(puzzle.rules === undefined ? {} : { rules: puzzle.rules }),
...(puzzle.solution === undefined
? {}
: { solution: [...puzzle.solution] }),
};
}
export function validatePuzzle(puzzle: unknown): ValidationResult {
const issues: ValidationIssue[] = [];
if (!isRecord(puzzle))
return {
valid: false,
issues: [{ path: "$", message: "must be an object" }],
};
for (const key of Object.keys(puzzle)) {
if (!ROOT_KEYS.has(key)) add(issues, key, "is not a recognized field");
}
if (puzzle.version !== 1) add(issues, "version", "must be 1");
const size = puzzle.size;
if (
!Number.isInteger(size) ||
(size as number) < MIN_PUZZLE_SIZE ||
(size as number) > MAX_PUZZLE_SIZE
) {
add(
issues,
"size",
`must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}`,
);
return { valid: false, issues };
}
const n = size as number;
const givensValid = validateValueArray(
puzzle.givens,
"givens",
n,
true,
issues,
);
let regionsValid = true;
if (puzzle.regions !== undefined) {
if (!Array.isArray(puzzle.regions)) {
add(issues, "regions", "must be an array");
regionsValid = false;
} else {
if (puzzle.regions.length !== n * n) {
add(issues, "regions", `must contain exactly ${n * n} values`);
regionsValid = false;
}
puzzle.regions.forEach((region, index) => {
if (!Number.isInteger(region) || region < 0 || region >= n) {
add(
issues,
`regions[${index}]`,
`must be an integer from 0 to ${n - 1}`,
);
regionsValid = false;
}
});
}
if (Array.isArray(puzzle.regions)) {
const counts = new Array<number>(n).fill(0);
puzzle.regions.forEach((region) => {
if (Number.isInteger(region) && region >= 0 && region < n) {
counts[region] = (counts[region] ?? 0) + 1;
}
});
counts.forEach((count, region) => {
if (count !== n)
add(
issues,
"regions",
`region ${region} must contain exactly ${n} cells`,
);
});
}
}
if (puzzle.constraints !== undefined && !Array.isArray(puzzle.constraints)) {
add(issues, "constraints", "must be an array");
} else if (Array.isArray(puzzle.constraints)) {
if (puzzle.constraints.length > MAX_CONSTRAINTS) {
add(
issues,
"constraints",
`must contain at most ${MAX_CONSTRAINTS} constraints`,
);
}
puzzle.constraints
.slice(0, MAX_CONSTRAINTS + 1)
.forEach((constraint, index) => {
validateConstraint(constraint, index, n, issues);
});
}
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);
validateText(puzzle.author, "author", MAX_SHORT_TEXT, issues);
validateText(puzzle.rules, "rules", MAX_RULES_TEXT, issues);
const solutionValid =
puzzle.solution === undefined ||
validateValueArray(puzzle.solution, "solution", n, false, issues);
if (
issues.length === 0 &&
givensValid &&
regionsValid &&
solutionValid &&
(puzzle.constraints === undefined || Array.isArray(puzzle.constraints))
) {
const normalized = normalizedUnchecked(
puzzle as unknown as PuzzleDefinition,
);
const compiled = compilePuzzle(normalized);
for (const conflict of findConflicts(compiled, normalized.givens)) {
add(issues, "givens", conflict.message);
}
if (normalized.solution !== undefined) {
for (let cell = 0; cell < normalized.givens.length; cell += 1) {
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && normalized.solution[cell] !== given) {
add(issues, `solution[${cell}]`, "must agree with the given value");
}
}
for (const conflict of findConflicts(compiled, normalized.solution)) {
add(issues, "solution", conflict.message);
}
}
}
return { valid: issues.length === 0, issues };
}
export function assertValidPuzzle(
puzzle: unknown,
): asserts puzzle is PuzzleDefinition {
const result = validatePuzzle(puzzle);
if (!result.valid) throw new PuzzleValidationError(result.issues);
}
export function normalizePuzzle(puzzle: PuzzleDefinition): NormalizedPuzzle {
assertValidPuzzle(puzzle);
return normalizedUnchecked(puzzle);
}
export function validateBoard(
puzzle: PuzzleDefinition | NormalizedPuzzle,
values: unknown,
): ValidationResult {
const normalized = normalizePuzzle(puzzle);
const issues: ValidationIssue[] = [];
const valuesValid = validateValueArray(
values,
"values",
normalized.size,
true,
issues,
);
if (valuesValid) {
values.forEach((value, cell) => {
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given)
add(issues, `values[${cell}]`, "must preserve the given value");
});
for (const conflict of findConflicts(compilePuzzle(normalized), values)) {
add(issues, "values", conflict.message);
}
}
return { valid: issues.length === 0, issues };
}
export function assertValidBoard(
puzzle: PuzzleDefinition | NormalizedPuzzle,
values: unknown,
): asserts values is readonly number[] {
const result = validateBoard(puzzle, values);
if (!result.valid) throw new PuzzleValidationError(result.issues);
}
+427
View File
@@ -0,0 +1,427 @@
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
cloneConstraint,
type PortableConstraint,
type SudokuDocument,
} from "./types";
export const MAX_DOCUMENT_BYTES = 1_048_576;
export const MIN_BOARD_SIZE = 4;
export const MAX_BOARD_SIZE = 16;
export const MAX_CONSTRAINTS = 5_000;
const MAX_TEXT_LENGTH = 20_000;
const MAX_RULES = 1_000;
export class SudokuFormatError extends Error {
readonly code: string;
constructor(code: string, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "SudokuFormatError";
this.code = code;
}
}
function fail(code: string, message: string): never {
throw new SudokuFormatError(code, message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function integer(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
!Number.isInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
return fail(
"INVALID_NUMBER",
`${label} must be an integer from ${minimum} to ${maximum}.`,
);
}
return value as number;
}
function optionalText(value: unknown, label: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string")
return fail("INVALID_TEXT", `${label} must be text.`);
if (value.length > MAX_TEXT_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
`${label} exceeds ${MAX_TEXT_LENGTH.toLocaleString()} characters.`,
);
}
return value;
}
function numberArray(
value: unknown,
label: string,
expectedLength: number,
minimum: number,
maximum: number,
): number[] {
if (!Array.isArray(value) || value.length !== expectedLength) {
return fail(
"INVALID_GRID",
`${label} must contain exactly ${expectedLength} entries.`,
);
}
return value.map((entry, index) =>
integer(entry, `${label}[${index}]`, minimum, maximum),
);
}
function cell(value: unknown, label: string, cellCount: number): number {
return integer(value, label, 0, cellCount - 1);
}
function cells(
value: unknown,
label: string,
cellCount: number,
minimumLength = 1,
): number[] {
if (
!Array.isArray(value) ||
value.length < minimumLength ||
value.length > cellCount
) {
return fail(
"INVALID_CELLS",
`${label} must contain ${minimumLength} to ${cellCount} cell indices.`,
);
}
const result = value.map((entry, index) =>
cell(entry, `${label}[${index}]`, cellCount),
);
if (new Set(result).size !== result.length) {
return fail("DUPLICATE_CELL", `${label} contains a duplicate cell.`);
}
return result;
}
function pair(
record: Record<string, unknown>,
cellCount: number,
): { a: number; b: number } {
const a = cell(record.a, "constraint.a", cellCount);
const b = cell(record.b, "constraint.b", cellCount);
if (a === b)
return fail("DUPLICATE_CELL", "A relation must join two different cells.");
return { a, b };
}
function parseConstraint(
value: unknown,
cellCount: number,
): PortableConstraint {
if (!isRecord(value) || typeof value.type !== "string") {
return fail(
"INVALID_CONSTRAINT",
"Each constraint must be an object with a type.",
);
}
switch (value.type) {
case "diagonal": {
if (value.direction !== "main" && value.direction !== "anti") {
return fail(
"INVALID_CONSTRAINT",
"A diagonal direction must be main or anti.",
);
}
return { type: "diagonal", direction: value.direction };
}
case "anti-knight":
case "anti-king":
case "non-consecutive":
return { type: value.type };
case "killer-cage": {
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
const sum = integer(
value.sum,
"killer-cage.sum",
1,
MAX_BOARD_SIZE * cellCount,
);
if (value.noRepeat !== undefined && typeof value.noRepeat !== "boolean") {
return fail(
"INVALID_CONSTRAINT",
"killer-cage.noRepeat must be true or false.",
);
}
return {
type: "killer-cage",
cells: cageCells,
sum,
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
};
}
case "thermo":
case "renban":
case "palindrome":
return {
type: value.type,
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
};
case "arrow":
return {
type: "arrow",
bulb: cells(value.bulb, "arrow.bulb", cellCount),
line: cells(value.line, "arrow.line", cellCount),
};
case "kropki": {
const related = pair(value, cellCount);
if (value.kind !== "white" && value.kind !== "black") {
return fail(
"INVALID_CONSTRAINT",
"A Kropki kind must be white or black.",
);
}
return { type: "kropki", ...related, kind: value.kind };
}
case "xv": {
const related = pair(value, cellCount);
if (value.total !== 5 && value.total !== 10) {
return fail("INVALID_CONSTRAINT", "An XV total must be 5 or 10.");
}
return {
type: "xv",
...related,
total: value.total,
};
}
case "inequality": {
const lesser = cell(value.lesser, "inequality.lesser", cellCount);
const greater = cell(value.greater, "inequality.greater", cellCount);
if (lesser === greater) {
return fail(
"DUPLICATE_CELL",
"An inequality must join two different cells.",
);
}
return { type: "inequality", lesser, greater };
}
default:
return fail(
"UNSUPPORTED_CONSTRAINT",
`Unsupported constraint type: ${value.type}.`,
);
}
}
function textList(value: unknown, label: string): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > MAX_RULES) {
return fail(
"LIMIT_EXCEEDED",
`${label} must contain at most ${MAX_RULES} entries.`,
);
}
return value.map((entry, index) => {
if (typeof entry !== "string" || entry.length > MAX_TEXT_LENGTH) {
return fail("INVALID_TEXT", `${label}[${index}] is not bounded text.`);
}
return entry;
});
}
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
if (!isRecord(value))
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`);
}
if (value.version !== SUDOKU_DOCUMENT_VERSION) {
return fail(
"UNSUPPORTED_VERSION",
`Unsupported puzzle document version: ${String(value.version)}.`,
);
}
const size = integer(value.size, "size", MIN_BOARD_SIZE, MAX_BOARD_SIZE);
const cellCount = size * size;
const givens = numberArray(value.givens, "givens", cellCount, 0, size);
const values =
value.values === undefined
? undefined
: numberArray(value.values, "values", cellCount, 0, size);
if (values !== undefined) {
givens.forEach((given, index) => {
if (given !== 0 && values[index] !== given) {
return fail(
"INVALID_GRID",
`values[${index}] must preserve its given digit.`,
);
}
});
}
const solution =
value.solution === undefined
? undefined
: numberArray(value.solution, "solution", cellCount, 1, size);
const regions =
value.regions === undefined
? undefined
: numberArray(value.regions, "regions", cellCount, 0, size - 1);
const marks = (input: unknown, label: string): number[][] | undefined => {
if (input === undefined) return undefined;
if (!Array.isArray(input) || input.length !== cellCount) {
return fail(
"INVALID_GRID",
`${label} must contain exactly ${cellCount} entries.`,
);
}
return input.map((entry, index) => {
if (!Array.isArray(entry) || entry.length > size) {
return fail(
"INVALID_CANDIDATES",
`${label}[${index}] must be an array.`,
);
}
const parsed = entry.map((digit, digitIndex) =>
integer(digit, `${label}[${index}][${digitIndex}]`, 1, size),
);
return [...new Set(parsed)].sort((a, b) => a - b);
});
};
const cornerMarks = marks(value.cornerMarks, "cornerMarks");
const centerMarks = marks(value.centerMarks, "centerMarks");
const candidates = marks(value.candidates, "candidates");
const colors =
value.colors === undefined
? undefined
: numberArray(value.colors, "colors", cellCount, 0, 8);
let elapsedMs: number | undefined;
if (value.elapsedMs !== undefined) {
if (
typeof value.elapsedMs !== "number" ||
!Number.isFinite(value.elapsedMs) ||
value.elapsedMs < 0
) {
return fail(
"INVALID_NUMBER",
"elapsedMs must be a non-negative finite number.",
);
}
elapsedMs = value.elapsedMs;
}
if (
!Array.isArray(value.constraints) ||
value.constraints.length > MAX_CONSTRAINTS
) {
return fail(
"LIMIT_EXCEEDED",
`constraints must contain at most ${MAX_CONSTRAINTS.toLocaleString()} entries.`,
);
}
const constraints = value.constraints.map((constraint) =>
parseConstraint(constraint, cellCount),
);
const title = optionalText(value.title, "title");
const author = optionalText(value.author, "author");
const id = optionalText(value.id, "id");
const rules = textList(value.rules, "rules");
const globalRules = textList(value.globalRules, "globalRules");
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
constraints,
...(values === undefined ? {} : { values }),
...(solution === undefined ? {} : { solution }),
...(cornerMarks === undefined ? {} : { cornerMarks }),
...(centerMarks === undefined ? {} : { centerMarks }),
...(candidates === undefined ? {} : { candidates }),
...(colors === undefined ? {} : { colors }),
...(elapsedMs === undefined ? {} : { elapsedMs }),
...(regions === undefined ? {} : { regions }),
...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }),
...(id === undefined ? {} : { id }),
...(rules === undefined ? {} : { rules }),
...(globalRules === undefined ? {} : { globalRules }),
};
}
export function parseSudokuDocument(input: string): SudokuDocument {
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
`Puzzle JSON exceeds ${MAX_DOCUMENT_BYTES.toLocaleString()} bytes.`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(input) as unknown;
} catch (error) {
throw new SudokuFormatError(
"INVALID_JSON",
"The puzzle is not valid JSON.",
{ cause: error },
);
}
return normalizeSudokuDocument(parsed);
}
export function serializeSudokuDocument(
value: SudokuDocument,
pretty = false,
): string {
const normalized = normalizeSudokuDocument(value);
const result = JSON.stringify(normalized, null, pretty ? 2 : undefined);
if (new TextEncoder().encode(result).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
`Puzzle JSON exceeds ${MAX_DOCUMENT_BYTES.toLocaleString()} bytes.`,
);
}
return result;
}
export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
const normalized = normalizeSudokuDocument(value);
return {
...normalized,
givens: [...normalized.givens],
constraints: normalized.constraints.map(cloneConstraint),
...(normalized.values === undefined
? {}
: { values: [...normalized.values] }),
...(normalized.solution === undefined
? {}
: { solution: [...normalized.solution] }),
...(normalized.cornerMarks === undefined
? {}
: { cornerMarks: normalized.cornerMarks.map((entry) => [...entry]) }),
...(normalized.centerMarks === undefined
? {}
: { centerMarks: normalized.centerMarks.map((entry) => [...entry]) }),
...(normalized.candidates === undefined
? {}
: { candidates: normalized.candidates.map((entry) => [...entry]) }),
...(normalized.colors === undefined
? {}
: { colors: [...normalized.colors] }),
...(normalized.regions === undefined
? {}
: { regions: [...normalized.regions] }),
...(normalized.rules === undefined ? {} : { rules: [...normalized.rules] }),
...(normalized.globalRules === undefined
? {}
: { globalRules: [...normalized.globalRules] }),
};
}
+740
View File
@@ -0,0 +1,740 @@
import {
compressToBase64,
decompressFromBase64,
decompressFromEncodedURIComponent,
} from "lz-string";
import {
MAX_BOARD_SIZE,
MAX_DOCUMENT_BYTES,
MIN_BOARD_SIZE,
SudokuFormatError,
} from "./document";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type PortableConstraint,
type SudokuDocument,
} from "./types";
export const MAX_FPUZZLES_PAYLOAD_LENGTH = 262_144;
const SUPPORTED_ROOT_FIELDS = new Set([
"size",
"grid",
"title",
"author",
"ruleset",
"solution",
"diagonal+",
"diagonal-",
"antiknight",
"antiking",
"antikingsmove",
"nonconsecutive",
"killercage",
"thermometer",
"arrow",
"difference",
"ratio",
"xv",
"inequality",
"renban",
"palindrome",
"disabledlogic",
"truecandidatesoptions",
"successMessage",
"successmessage",
]);
const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
disjointgroups: "disjoint groups",
littlekillersum: "little killer sums",
sandwichsum: "sandwich sums",
even: "even cells",
odd: "odd cells",
extraregion: "extra regions",
clone: "clone regions",
quadruple: "quadruples",
betweenline: "between lines",
minimum: "minimum cells",
maximum: "maximum cells",
whispers: "whisper lines",
regionsumline: "region-sum lines",
entropicline: "entropic lines",
modularline: "modular lines",
zipperline: "zipper lines",
nabner: "Nabner lines",
doublearrow: "double arrows",
lockout: "lockout lines",
rowindexer: "row indexers",
columnindexer: "column indexers",
boxindexer: "box indexers",
xsum: "X-sums",
skyscraper: "skyscrapers",
fogofwar: "fog of war",
foglight: "fog lights",
cage: "generic cages",
negative: "negative constraints",
};
const DECORATION_FIELDS: Readonly<Record<string, string>> = {
line: "decorative lines",
rectangle: "rectangles",
circle: "circles",
text: "text decorations",
};
export class NetworkPuzzleIdError extends SudokuFormatError {
readonly puzzleId: string;
constructor(puzzleId: string) {
super(
"NETWORK_PUZZLE_ID",
`${puzzleId}” is a server-hosted SudokuPad puzzle ID. This local-only app cannot fetch short puzzle IDs; paste an inline fpuzzles URL or raw fpuzzles JSON instead.`,
);
this.name = "NetworkPuzzleIdError";
this.puzzleId = puzzleId;
}
}
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function fail(code: string, message: string): never {
throw new SudokuFormatError(code, message);
}
function present(value: unknown): boolean {
if (value === undefined || value === null || value === false) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "string") return value.length > 0;
return true;
}
function assertSupportedRootFields(value: JsonRecord): void {
for (const [field, raw] of Object.entries(value)) {
const unsupported = UNSUPPORTED_RULE_FIELDS[field];
if (unsupported !== undefined && present(raw)) {
return fail(
"UNSUPPORTED_FPUZZLES",
`This puzzle uses ${unsupported}, which Sudoku Tools cannot enforce yet. Import stopped rather than silently weakening the puzzle.`,
);
}
const decoration = DECORATION_FIELDS[field];
if (decoration !== undefined && present(raw)) {
return fail(
"UNSUPPORTED_FPUZZLES",
`This puzzle contains ${decoration}, which cannot be preserved yet. Import stopped rather than discarding them.`,
);
}
if (
!SUPPORTED_ROOT_FIELDS.has(field) &&
unsupported === undefined &&
decoration === undefined
) {
return fail(
"UNSUPPORTED_FPUZZLES",
`Unknown fpuzzles field “${field}”. Import stopped so a rule or visual cannot be silently lost.`,
);
}
}
}
function boundedJson(input: string): unknown {
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
"The fpuzzles JSON is too large to open safely.",
);
}
try {
return JSON.parse(input) as unknown;
} catch (error) {
throw new SudokuFormatError(
"INVALID_FPUZZLES",
"The fpuzzles data is not valid JSON.",
{
cause: error,
},
);
}
}
export function cellIndexFromAddress(value: unknown, size: number): number {
if (typeof value !== "string")
return fail("INVALID_CELL", "An fpuzzles cell must be RnCn text.");
const match = /^R(\d+)C(\d+)$/iu.exec(value.trim());
if (match === null)
return fail("INVALID_CELL", `Invalid fpuzzles cell address: ${value}.`);
const row = Number(match[1]);
const column = Number(match[2]);
if (row < 1 || row > size || column < 1 || column > size) {
return fail(
"INVALID_CELL",
`Cell ${value} is outside the ${size}×${size} grid.`,
);
}
return (row - 1) * size + column - 1;
}
export function addressFromCellIndex(index: number, size: number): string {
if (!Number.isInteger(index) || index < 0 || index >= size * size) {
return fail(
"INVALID_CELL",
`Cell index ${index} is outside the ${size}×${size} grid.`,
);
}
return `R${Math.floor(index / size) + 1}C${(index % size) + 1}`;
}
function fpCells(value: unknown, size: number, minimum = 1): number[] {
if (
!Array.isArray(value) ||
value.length < minimum ||
value.length > size * size
) {
return fail(
"INVALID_CELLS",
"An fpuzzles constraint contains an invalid cell list.",
);
}
const result = value.map((entry) => cellIndexFromAddress(entry, size));
if (new Set(result).size !== result.length) {
return fail(
"DUPLICATE_CELL",
"An fpuzzles constraint contains a duplicate cell.",
);
}
return result;
}
function objects(value: unknown, maximum = 5_000): JsonRecord[] {
if (value === undefined) return [];
if (
!Array.isArray(value) ||
value.length > maximum ||
!value.every(isRecord)
) {
return fail(
"INVALID_FPUZZLES",
"An fpuzzles constraint collection is invalid or too large.",
);
}
return value;
}
function lines(value: unknown, size: number): number[][] {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > size * size
) {
return fail("INVALID_CELLS", "An fpuzzles line collection is invalid.");
}
// Some producers use `lines: [[...]]`; tolerate a direct cell list as well.
if (value.every((entry) => typeof entry === "string"))
return [fpCells(value, size, 2)];
return value.map((line) => fpCells(line, size, 2));
}
function numeric(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
const parsed =
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
if (
!Number.isInteger(parsed) ||
(parsed as number) < minimum ||
(parsed as number) > maximum
) {
return fail("INVALID_FPUZZLES", `${label} is outside the supported range.`);
}
return parsed as number;
}
function readGrid(value: unknown, size: number): JsonRecord[] {
if (!Array.isArray(value))
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
const flattened =
value.length === size && value.every(Array.isArray) ? value.flat() : value;
if (flattened.length !== size * size || !flattened.every(isRecord)) {
return fail(
"INVALID_FPUZZLES",
`fpuzzles.grid must contain ${size * size} cells.`,
);
}
return flattened;
}
function readRegions(
grid: readonly JsonRecord[],
size: number,
): number[] | undefined {
const hasAny = grid.some((entry) => entry.region !== undefined);
if (!hasAny) return undefined;
return grid.map((entry, index) =>
entry.region === undefined
? fail(
"INVALID_FPUZZLES",
"A custom region map must assign every cell to a region.",
)
: numeric(entry.region, `grid[${index}].region`, 0, size - 1),
);
}
function addLineConstraints(
output: PortableConstraint[],
source: unknown,
size: number,
type: "thermo" | "renban" | "palindrome",
): void {
for (const item of objects(source)) {
for (const line of lines(item.lines ?? item.cells, size))
output.push({ type, cells: line });
}
}
function parseRules(value: unknown): string[] | undefined {
if (value === undefined || value === "") return undefined;
if (typeof value === "string") return [value];
if (
Array.isArray(value) &&
value.every((entry) => typeof entry === "string")
) {
return value.slice(0, 1_000);
}
return fail(
"INVALID_FPUZZLES",
"fpuzzles.ruleset must be text or a list of text rules.",
);
}
/** Convert already-decoded fpuzzles JSON into the bounded Sudoku Tools model. */
export function parseFpuzzles(value: unknown): SudokuDocument {
if (!isRecord(value))
return fail("INVALID_FPUZZLES", "The fpuzzles puzzle must be an object.");
assertSupportedRootFields(value);
const size = numeric(
value.size ?? 9,
"fpuzzles.size",
MIN_BOARD_SIZE,
MAX_BOARD_SIZE,
);
const grid = readGrid(value.grid, size);
const givens = grid.map((entry, index) => {
if (entry.given !== true) return 0;
return numeric(entry.value, `grid[${index}].value`, 1, size);
});
const values = grid.map((entry, index) =>
entry.value === undefined || entry.value === null || entry.value === ""
? 0
: numeric(entry.value, `grid[${index}].value`, 1, size),
);
const readMarks = (field: "centerPencilMarks" | "cornerPencilMarks") => {
const parsed = grid.map((entry, cellIndex) => {
const raw = entry[field];
if (raw === undefined || raw === null) return [];
if (!Array.isArray(raw) || raw.length > size) {
return fail(
"INVALID_FPUZZLES",
`grid[${cellIndex}].${field} must be a bounded digit array.`,
);
}
return [
...new Set(
raw.map((mark, markIndex) =>
numeric(mark, `grid[${cellIndex}].${field}[${markIndex}]`, 1, size),
),
),
].sort((a, b) => a - b);
});
return parsed.some((entry) => entry.length > 0) ? parsed : undefined;
};
const centerMarks = readMarks("centerPencilMarks");
const cornerMarks = readMarks("cornerPencilMarks");
const solution =
value.solution === undefined
? undefined
: Array.isArray(value.solution) && value.solution.length === size * size
? value.solution.map((entry, index) =>
numeric(entry, `solution[${index}]`, 1, size),
)
: fail(
"INVALID_FPUZZLES",
`solution must contain exactly ${size * size} digits.`,
);
const constraints: PortableConstraint[] = [];
if (value["diagonal+"] === true)
constraints.push({ type: "diagonal", direction: "main" });
if (value["diagonal-"] === true)
constraints.push({ type: "diagonal", direction: "anti" });
if (value.antiknight === true) constraints.push({ type: "anti-knight" });
if (value.antiking === true || value.antikingsmove === true)
constraints.push({ type: "anti-king" });
if (value.nonconsecutive === true)
constraints.push({ type: "non-consecutive" });
for (const cage of objects(value.killercage)) {
if (cage.value === undefined || cage.value === "") {
return fail(
"UNSUPPORTED_FPUZZLES",
"A killer cage without a sum cannot be imported.",
);
}
const sum = numeric(cage.value, "killercage.value", 1, size * size * size);
constraints.push({
type: "killer-cage",
cells: fpCells(cage.cells, size),
sum,
noRepeat: cage.unique !== false,
});
}
addLineConstraints(constraints, value.thermometer, size, "thermo");
addLineConstraints(constraints, value.renban, size, "renban");
addLineConstraints(constraints, value.palindrome, size, "palindrome");
for (const arrow of objects(value.arrow)) {
const bulb = fpCells(arrow.cells, size);
for (const line of lines(arrow.lines, size)) {
const path = line.filter((cell) => !bulb.includes(cell));
if (path.length === 0) {
return fail(
"INVALID_FPUZZLES",
"An arrow line must extend beyond its bulb.",
);
}
constraints.push({ type: "arrow", bulb, line: path });
}
}
for (const dot of objects(value.difference)) {
const dotCells = fpCells(dot.cells, size, 2);
if (dotCells.length !== 2)
return fail("INVALID_CELLS", "A difference dot needs two cells.");
const difference = numeric(dot.value ?? 1, "difference.value", 1, size - 1);
const [a, b] = dotCells as [number, number];
if (difference !== 1) {
return fail(
"UNSUPPORTED_FPUZZLES",
`Difference-${difference} dots are not supported; only standard white Kropki dots are available.`,
);
}
constraints.push({ type: "kropki", a, b, kind: "white" });
}
for (const dot of objects(value.ratio)) {
const dotCells = fpCells(dot.cells, size, 2);
if (dotCells.length !== 2)
return fail("INVALID_CELLS", "A ratio dot needs two cells.");
const ratio = numeric(dot.value ?? 2, "ratio.value", 2, size);
const [a, b] = dotCells as [number, number];
if (ratio !== 2) {
return fail(
"UNSUPPORTED_FPUZZLES",
`Ratio-${ratio} dots are not supported; only standard black Kropki dots are available.`,
);
}
constraints.push({ type: "kropki", a, b, kind: "black" });
}
for (const xv of objects(value.xv)) {
const xvCells = fpCells(xv.cells, size, 2);
if (xvCells.length !== 2)
return fail("INVALID_CELLS", "An XV clue needs two cells.");
const total =
typeof xv.value === "string" && xv.value.toUpperCase() === "V"
? 5
: typeof xv.value === "string" && xv.value.toUpperCase() === "X"
? 10
: numeric(xv.value, "xv.value", 1, size * 2);
if (total !== 5 && total !== 10) {
return fail(
"UNSUPPORTED_FPUZZLES",
`XV total ${total} is not supported; expected 5 or 10.`,
);
}
const [a, b] = xvCells as [number, number];
constraints.push({ type: "xv", a, b, total });
}
for (const inequality of objects(value.inequality)) {
const inequalityCells = fpCells(inequality.cells, size, 2);
if (inequalityCells.length !== 2) {
return fail("INVALID_CELLS", "An inequality needs two cells.");
}
const [a, b] = inequalityCells as [number, number];
if (inequality.value === ">")
constraints.push({ type: "inequality", lesser: b, greater: a });
else constraints.push({ type: "inequality", lesser: a, greater: b });
}
const regions = readRegions(grid, size);
const rules = parseRules(value.ruleset);
const title =
typeof value.title === "string" ? value.title.slice(0, 20_000) : undefined;
const author =
typeof value.author === "string"
? value.author.slice(0, 20_000)
: undefined;
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
values,
constraints,
...(solution === undefined ? {} : { solution }),
...(cornerMarks === undefined ? {} : { cornerMarks }),
...(centerMarks === undefined ? {} : { centerMarks }),
...(regions === undefined ? {} : { regions }),
...(rules === undefined ? {} : { rules }),
...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }),
};
}
function constraintCells(cells: readonly number[], size: number): string[] {
return cells.map((cell) => addressFromCellIndex(cell, size));
}
export function exportFpuzzles(document: SudokuDocument): JsonRecord {
const { size } = document;
const output: JsonRecord = {
size,
grid: Array.from({ length: size }, (_, row) =>
Array.from({ length: size }, (_unused, column) => {
const index = row * size + column;
const given = document.givens[index] ?? 0;
const value = given || document.values?.[index] || 0;
return {
...(value === 0 ? {} : { value }),
...(given === 0 ? {} : { given: true }),
...((document.centerMarks ?? document.candidates)?.[index]?.length
? {
centerPencilMarks: [
...(document.centerMarks ?? document.candidates)![index]!,
],
}
: {}),
...(document.cornerMarks?.[index]?.length
? { cornerPencilMarks: [...document.cornerMarks[index]!] }
: {}),
...(document.regions === undefined || document.regions[index] === -1
? {}
: { region: document.regions[index] }),
};
}),
),
...(document.title === undefined ? {} : { title: document.title }),
...(document.author === undefined ? {} : { author: document.author }),
...(document.solution === undefined
? {}
: { solution: [...document.solution] }),
...(document.rules === undefined
? {}
: { ruleset: document.rules.join("\n") }),
};
const append = (field: string, item: JsonRecord): void => {
const collection = output[field];
if (collection === undefined) output[field] = [item];
else (collection as JsonRecord[]).push(item);
};
for (const constraint of document.constraints) {
switch (constraint.type) {
case "diagonal":
output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] =
true;
break;
case "anti-knight":
output.antiknight = true;
break;
case "anti-king":
output.antikingsmove = true;
break;
case "non-consecutive":
output.nonconsecutive = true;
break;
case "killer-cage":
append("killercage", {
cells: constraintCells(constraint.cells, size),
...(constraint.sum === undefined
? {}
: { value: String(constraint.sum) }),
...(constraint.noRepeat === false ? { unique: false } : {}),
});
break;
case "thermo":
append("thermometer", {
lines: [constraintCells(constraint.cells, size)],
});
break;
case "renban":
case "palindrome":
append(constraint.type, {
lines: [constraintCells(constraint.cells, size)],
});
break;
case "arrow":
append("arrow", {
cells: constraintCells(constraint.bulb, size),
lines: [
constraintCells(
[
constraint.bulb.at(-1)!,
...constraint.line.filter(
(cell) => !constraint.bulb.includes(cell),
),
],
size,
),
],
});
break;
case "kropki":
append(constraint.kind === "white" ? "difference" : "ratio", {
cells: constraintCells([constraint.a, constraint.b], size),
value: constraint.kind === "white" ? "1" : "2",
});
break;
case "xv":
append("xv", {
cells: constraintCells([constraint.a, constraint.b], size),
value:
constraint.total === 5
? "V"
: constraint.total === 10
? "X"
: String(constraint.total),
});
break;
case "inequality":
append("inequality", {
cells: constraintCells([constraint.lesser, constraint.greater], size),
value: "<",
});
break;
}
}
if ((document.globalRules?.length ?? 0) > 0) {
return fail(
"UNSUPPORTED_FPUZZLES",
"This project contains global rules that fpuzzles export cannot preserve safely.",
);
}
return output;
}
function extractPayload(input: string): string | undefined {
const trimmed = input.trim();
if (trimmed.startsWith("{")) return undefined;
let candidate = trimmed;
if (/^https?:\/\//iu.test(trimmed)) {
let url: URL;
try {
url = new URL(trimmed);
} catch (error) {
throw new SudokuFormatError(
"INVALID_FPUZZLES_URL",
"The fpuzzles URL is invalid.",
{
cause: error,
},
);
}
const queryId =
url.searchParams.get("puzzleid") ?? url.searchParams.get("load");
candidate = queryId ?? decodeURIComponent(url.pathname.replace(/^\//u, ""));
}
if (candidate.startsWith("fpuzzles"))
return candidate.slice("fpuzzles".length);
if (/^[\w-]{1,128}$/u.test(candidate))
throw new NetworkPuzzleIdError(candidate);
return candidate;
}
function decodePayload(payload: string): string {
if (payload.length === 0 || payload.length > MAX_FPUZZLES_PAYLOAD_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
"The compressed fpuzzles payload is empty or too large.",
);
}
let decodedPayload = payload;
try {
decodedPayload = decodeURIComponent(payload);
} catch {
// URLSearchParams already decodes input. Keep the original if a literal % is malformed.
}
const base64 = decompressFromBase64(decodedPayload.replaceAll(" ", "+"));
const uriEncoded =
base64 || decompressFromEncodedURIComponent(decodedPayload);
if (uriEncoded === null || uriEncoded === "") {
return fail(
"INVALID_FPUZZLES",
"The inline fpuzzles payload could not be decompressed.",
);
}
if (new TextEncoder().encode(uriEncoded).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
"The decompressed fpuzzles puzzle is too large to open safely.",
);
}
return uriEncoded;
}
/** Import raw JSON, f-puzzles load URLs, or inline SudokuPad fpuzzles URLs. */
export function importFpuzzles(input: string): SudokuDocument {
const payload = extractPayload(input);
return parseFpuzzles(
boundedJson(payload === undefined ? input : decodePayload(payload)),
);
}
export function exportFpuzzlesJson(
document: SudokuDocument,
pretty = false,
): string {
const json = JSON.stringify(
exportFpuzzles(document),
null,
pretty ? 2 : undefined,
);
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
return fail("LIMIT_EXCEEDED", "The exported fpuzzles JSON is too large.");
}
return json;
}
export function exportFpuzzlesPayload(document: SudokuDocument): string {
const payload = compressToBase64(exportFpuzzlesJson(document));
if (payload.length > MAX_FPUZZLES_PAYLOAD_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
"The compressed fpuzzles payload is too large.",
);
}
return encodeURIComponent(payload);
}
export function exportFpuzzlesUrl(
document: SudokuDocument,
baseUrl = "https://sudokupad.app/",
): string {
const url = new URL(baseUrl);
url.searchParams.set(
"puzzleid",
`fpuzzles${decodeURIComponent(exportFpuzzlesPayload(document))}`,
);
return url.toString();
}
+105
View File
@@ -0,0 +1,105 @@
import { MAX_BOARD_SIZE, MIN_BOARD_SIZE, SudokuFormatError } from "./document";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type SudokuDocument,
} from "./types";
const SEPARATORS = /[\s|+-]/gu;
function decodeSymbol(symbol: string): number {
if (symbol === "." || symbol === "0") return 0;
if (symbol >= "1" && symbol <= "9") return Number(symbol);
const code = symbol.toUpperCase().charCodeAt(0);
if (code >= 65 && code <= 80) return code - 55;
throw new SudokuFormatError(
"INVALID_GRID_SYMBOL",
`Unsupported grid symbol ${JSON.stringify(symbol)}. Use 0 or . for an empty cell.`,
);
}
function encodeSymbol(value: number): string {
if (value === 0) return ".";
if (value <= 9) return String(value);
return String.fromCharCode(value + 55);
}
export interface PlainGridOptions {
readonly size?: number;
readonly title?: string;
readonly author?: string;
}
export function parsePlainGrid(
input: string,
options: PlainGridOptions = {},
): SudokuDocument {
const compact = input.replace(/^\uFEFF/u, "").replace(SEPARATORS, "");
const inferred = Math.sqrt(compact.length);
const size = options.size ?? inferred;
if (
!Number.isInteger(size) ||
size < MIN_BOARD_SIZE ||
size > MAX_BOARD_SIZE
) {
throw new SudokuFormatError(
"INVALID_GRID_SIZE",
`The grid must have a square number of cells and a side length from ${MIN_BOARD_SIZE} to ${MAX_BOARD_SIZE}.`,
);
}
if (compact.length !== size * size) {
throw new SudokuFormatError(
"INVALID_GRID_LENGTH",
`Expected ${size * size} cell symbols for a ${size}×${size} grid, but found ${compact.length}.`,
);
}
const givens = [...compact].map((symbol, index) => {
const value = decodeSymbol(symbol);
if (value > size) {
throw new SudokuFormatError(
"INVALID_GRID_SYMBOL",
`Cell ${index + 1} contains ${symbol}, which is outside 1${size}.`,
);
}
return value;
});
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
constraints: [],
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
}
export function serializePlainGrid(
document: Pick<SudokuDocument, "size" | "givens" | "values">,
source: "givens" | "values" = "givens",
): string {
const values = source === "values" ? document.values : document.givens;
if (values === undefined) {
throw new SudokuFormatError(
"MISSING_GRID",
"This puzzle has no current values to export.",
);
}
if (values.length !== document.size * document.size) {
throw new SudokuFormatError(
"INVALID_GRID_LENGTH",
"The grid does not match the puzzle size.",
);
}
return values
.map((value, index) => {
if (!Number.isInteger(value) || value < 0 || value > document.size) {
throw new SudokuFormatError(
"INVALID_GRID_SYMBOL",
`Cell ${index + 1} is out of range.`,
);
}
return encodeSymbol(value);
})
.join("");
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./document";
export * from "./fpuzzles";
export * from "./grid";
export * from "./share";
export * from "./types";
+66
View File
@@ -0,0 +1,66 @@
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from "lz-string";
import {
MAX_DOCUMENT_BYTES,
SudokuFormatError,
parseSudokuDocument,
serializeSudokuDocument,
} from "./document";
import type { SudokuDocument } from "./types";
export const PUZZLE_HASH_PREFIX = "#sudoku=v1.";
export const MAX_SHARE_HASH_LENGTH = 131_072;
export function encodePuzzleHash(document: SudokuDocument): string {
const payload = compressToEncodedURIComponent(
serializeSudokuDocument(document),
);
const hash = `${PUZZLE_HASH_PREFIX}${payload}`;
if (hash.length > MAX_SHARE_HASH_LENGTH) {
throw new SudokuFormatError(
"LIMIT_EXCEEDED",
`The compressed puzzle exceeds the ${MAX_SHARE_HASH_LENGTH.toLocaleString()} character share limit.`,
);
}
return hash;
}
export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
let hash = hashOrUrl.trim();
try {
if (/^[a-z][a-z\d+.-]*:\/\//iu.test(hash)) hash = new URL(hash).hash;
} catch (error) {
throw new SudokuFormatError("INVALID_SHARE", "The share URL is invalid.", {
cause: error,
});
}
if (!hash.startsWith(PUZZLE_HASH_PREFIX)) {
throw new SudokuFormatError(
"INVALID_SHARE",
`Expected a hash beginning with ${PUZZLE_HASH_PREFIX}.`,
);
}
if (hash.length > MAX_SHARE_HASH_LENGTH) {
throw new SudokuFormatError(
"LIMIT_EXCEEDED",
"The share hash is too large to open safely.",
);
}
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length);
const json = decompressFromEncodedURIComponent(compressed);
if (json === null || json === "") {
throw new SudokuFormatError(
"INVALID_SHARE",
"The share payload could not be decompressed.",
);
}
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
throw new SudokuFormatError(
"LIMIT_EXCEEDED",
"The decompressed puzzle is too large to open safely.",
);
}
return parseSudokuDocument(json);
}
+148
View File
@@ -0,0 +1,148 @@
export const SUDOKU_DOCUMENT_SCHEMA =
"de.add-ideas.sudoku-tools.puzzle" as const;
export const SUDOKU_DOCUMENT_VERSION = 1 as const;
export type CellIndex = number;
export type PortableConstraint =
| { readonly type: "diagonal"; readonly direction: "main" | "anti" }
| { readonly type: "anti-knight" }
| { readonly type: "anti-king" }
| { readonly type: "non-consecutive" }
| {
readonly type: "killer-cage";
readonly cells: readonly CellIndex[];
readonly sum: number;
readonly noRepeat?: boolean;
}
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] }
| {
readonly type: "arrow";
readonly bulb: readonly CellIndex[];
readonly line: readonly CellIndex[];
}
| {
readonly type: "kropki";
readonly a: CellIndex;
readonly b: CellIndex;
readonly kind: "white" | "black";
}
| {
readonly type: "xv";
readonly a: CellIndex;
readonly b: CellIndex;
readonly total: 5 | 10;
}
| {
readonly type: "inequality";
readonly lesser: CellIndex;
readonly greater: CellIndex;
}
| { readonly type: "renban"; readonly cells: readonly CellIndex[] }
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] };
/**
* Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
* row-major and zero based. Zero denotes an empty grid value.
*/
export interface SudokuDocument {
readonly schema: typeof SUDOKU_DOCUMENT_SCHEMA;
readonly version: typeof SUDOKU_DOCUMENT_VERSION;
readonly size: number;
readonly givens: readonly number[];
readonly values?: readonly number[];
readonly cornerMarks?: readonly (readonly number[])[];
readonly centerMarks?: readonly (readonly number[])[];
/** Legacy v1 alias for centre marks. */
readonly candidates?: readonly (readonly number[])[];
readonly colors?: readonly number[];
readonly elapsedMs?: number;
readonly solution?: readonly number[];
readonly regions?: readonly number[];
readonly constraints: readonly PortableConstraint[];
readonly title?: string;
readonly author?: string;
readonly rules?: readonly string[];
/** Named boolean/global rules which cannot be represented by a local shape. */
readonly globalRules?: readonly string[];
readonly id?: string;
}
/** Minimal structural type accepted by the domain adapter. */
export interface DomainPuzzleShape {
readonly version: 1;
readonly size: number;
readonly givens: readonly number[];
readonly regions?: readonly number[];
readonly constraints?: readonly PortableConstraint[];
readonly title?: string;
readonly author?: string;
readonly id?: string;
readonly rules?: string;
readonly solution?: readonly number[];
}
export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape {
if ((document.globalRules?.length ?? 0) > 0) {
throw new Error(
"This document contains global rules that the current puzzle engine cannot enforce.",
);
}
return {
version: 1,
size: document.size,
givens: [...document.givens],
...(document.regions === undefined
? {}
: { regions: [...document.regions] }),
constraints: document.constraints.map(cloneConstraint),
...(document.title === undefined ? {} : { title: document.title }),
...(document.author === undefined ? {} : { author: document.author }),
...(document.id === undefined ? {} : { id: document.id }),
...(document.rules === undefined
? {}
: { rules: document.rules.join("\n") }),
...(document.solution === undefined
? {}
: { solution: [...document.solution] }),
};
}
export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size: puzzle.size,
givens: [...puzzle.givens],
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
...(puzzle.solution === undefined
? {}
: { solution: [...puzzle.solution] }),
};
}
export function cloneConstraint(
constraint: PortableConstraint,
): PortableConstraint {
switch (constraint.type) {
case "killer-cage":
return { ...constraint, cells: [...constraint.cells] };
case "thermo":
case "renban":
case "palindrome":
return { ...constraint, cells: [...constraint.cells] };
case "arrow":
return {
...constraint,
bulb: [...constraint.bulb],
line: [...constraint.line],
};
default:
return { ...constraint };
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./killer";
export * from "./relations";
export * from "./residual";
+252
View File
@@ -0,0 +1,252 @@
export const MAX_KILLER_COMBINATIONS = 20_000;
export const MAX_KILLER_ASSIGNMENTS = 50_000;
export interface KillerCombinationOptions {
readonly cellCount: number;
readonly sum: number;
readonly size?: number;
readonly allowedDigits?: readonly number[];
readonly requiredDigits?: readonly number[];
readonly excludedDigits?: readonly number[];
readonly allowRepeats?: boolean;
/** Candidate digits for each cage cell, in cell order. */
readonly candidates?: readonly (readonly number[])[];
readonly maxCombinations?: number;
readonly maxAssignments?: number;
}
export interface KillerAnalysis {
/** Sorted multisets. Each logical combination occurs at most once. */
readonly combinations: readonly (readonly number[])[];
/** Candidate-compatible, cell-ordered assignments (bounded). */
readonly assignments: readonly (readonly number[])[];
readonly possibleDigits: readonly number[];
/** Digits present in every feasible combination. */
readonly necessaryDigits: readonly number[];
readonly possibleByCell: readonly (readonly number[])[];
readonly truncated: boolean;
}
function boundedInteger(
value: number,
label: string,
minimum: number,
maximum: number,
): number {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${label} must be an integer from ${minimum} to ${maximum}.`,
);
}
return value;
}
function uniqueDigits(
values: readonly number[],
size: number,
label: string,
): number[] {
const output = [...new Set(values)];
for (const value of output) boundedInteger(value, label, 1, size);
return output.sort((a, b) => a - b);
}
function intersection(
left: ReadonlySet<number>,
right: ReadonlySet<number>,
): Set<number> {
return new Set([...left].filter((value) => right.has(value)));
}
/**
* Enumerate killer sums without permutations, optionally filter them through
* per-cell candidates, and derive digits that are possible/necessary.
*/
export function analyzeKillerCage(
options: KillerCombinationOptions,
): KillerAnalysis {
const size = boundedInteger(options.size ?? 9, "size", 1, 25);
const cellCount = boundedInteger(options.cellCount, "cellCount", 1, 25);
const sum = boundedInteger(options.sum, "sum", 1, size * cellCount);
const allowRepeats = options.allowRepeats ?? false;
if (!allowRepeats && cellCount > size) {
return {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from({ length: cellCount }, () => []),
truncated: false,
};
}
const excluded = new Set(
uniqueDigits(options.excludedDigits ?? [], size, "excluded digit"),
);
const allowed = uniqueDigits(
options.allowedDigits ??
Array.from({ length: size }, (_, index) => index + 1),
size,
"allowed digit",
).filter((digit) => !excluded.has(digit));
const required = uniqueDigits(
options.requiredDigits ?? [],
size,
"required digit",
);
if (required.some((digit) => !allowed.includes(digit))) {
return {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from({ length: cellCount }, () => []),
truncated: false,
};
}
if (!allowRepeats && required.length > cellCount) {
return {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from({ length: cellCount }, () => []),
truncated: false,
};
}
let candidateSets: Set<number>[] | undefined;
if (options.candidates !== undefined) {
if (options.candidates.length !== cellCount) {
throw new RangeError(
`candidates must contain exactly ${cellCount} cell entries.`,
);
}
candidateSets = options.candidates.map(
(entry) =>
new Set(
uniqueDigits(entry, size, "candidate").filter((digit) =>
allowed.includes(digit),
),
),
);
}
const maxCombinations = boundedInteger(
options.maxCombinations ?? 5_000,
"maxCombinations",
1,
MAX_KILLER_COMBINATIONS,
);
const maxAssignments = boundedInteger(
options.maxAssignments ?? 10_000,
"maxAssignments",
1,
MAX_KILLER_ASSIGNMENTS,
);
const combinations: number[][] = [];
const assignments: number[][] = [];
const possibleByCell = Array.from(
{ length: cellCount },
() => new Set<number>(),
);
let necessary: Set<number> | undefined;
let truncated = false;
const compatibleAssignments = (
combination: readonly number[],
): number[][] => {
if (candidateSets === undefined) return [[...combination]];
const counts = new Map<number, number>();
for (const digit of combination)
counts.set(digit, (counts.get(digit) ?? 0) + 1);
const result: number[][] = [];
const current = Array<number>(cellCount).fill(0);
const visit = (cellPosition: number): void => {
if (assignments.length + result.length >= maxAssignments) {
truncated = true;
return;
}
if (cellPosition === cellCount) {
result.push([...current]);
return;
}
const candidates = candidateSets[cellPosition];
if (candidates === undefined) return;
for (const digit of [...counts.keys()].sort((a, b) => a - b)) {
const remaining = counts.get(digit) ?? 0;
if (remaining === 0 || !candidates.has(digit)) continue;
counts.set(digit, remaining - 1);
current[cellPosition] = digit;
visit(cellPosition + 1);
counts.set(digit, remaining);
if (truncated) return;
}
};
visit(0);
return result;
};
const accept = (combination: readonly number[]): void => {
if (!required.every((digit) => combination.includes(digit))) return;
const feasible = compatibleAssignments(combination);
if (feasible.length === 0) return;
combinations.push([...combination]);
const digitSet = new Set(combination);
necessary =
necessary === undefined ? digitSet : intersection(necessary, digitSet);
if (candidateSets === undefined) {
for (const possible of possibleByCell)
for (const digit of digitSet) possible.add(digit);
} else {
for (const assignment of feasible) {
for (const [position, digit] of assignment.entries())
possibleByCell[position]?.add(digit);
}
}
assignments.push(...feasible);
};
const current: number[] = [];
const search = (start: number, currentSum: number): void => {
if (truncated) return;
const remaining = cellCount - current.length;
if (remaining === 0) {
if (currentSum === sum) accept(current);
if (combinations.length >= maxCombinations) truncated = true;
return;
}
if (currentSum >= sum || allowed.length === 0) return;
for (let index = start; index < allowed.length; index += 1) {
const digit = allowed[index];
if (digit === undefined) continue;
const nextSum = currentSum + digit;
if (nextSum > sum) break;
current.push(digit);
search(allowRepeats ? index : index + 1, nextSum);
current.pop();
if (truncated) return;
}
};
search(0, 0);
const possibleDigits = [...new Set(combinations.flat())].sort(
(a, b) => a - b,
);
return {
combinations,
assignments,
possibleDigits,
necessaryDigits: [...(necessary ?? [])].sort((a, b) => a - b),
possibleByCell: possibleByCell.map((entry) =>
[...entry].sort((a, b) => a - b),
),
truncated,
};
}
export function calculateKillerCombinations(
options: Omit<KillerCombinationOptions, "candidates">,
): readonly (readonly number[])[] {
return analyzeKillerCage(options).combinations;
}
+100
View File
@@ -0,0 +1,100 @@
export type DigitPair = readonly [number, number];
export type RelationSpec =
| { readonly type: "kropki"; readonly kind: "white" | "black" }
| { readonly type: "difference"; readonly difference: number }
| { readonly type: "ratio"; readonly ratio: number }
| { readonly type: "xv"; readonly total: number }
| { readonly type: "inequality"; readonly relation: "<" | ">" };
function digits(size: number, candidates?: readonly number[]): number[] {
if (!Number.isInteger(size) || size < 1 || size > 25) {
throw new RangeError("size must be an integer from 1 to 25.");
}
const result = [
...new Set(
candidates ?? Array.from({ length: size }, (_, index) => index + 1),
),
];
if (
result.some(
(digit) => !Number.isInteger(digit) || digit < 1 || digit > size,
)
) {
throw new RangeError(`Candidates must be digits from 1 to ${size}.`);
}
return result.sort((a, b) => a - b);
}
/** Ordered pairs: the first tuple member belongs to the first selected cell. */
export function relationPairs(
specification: RelationSpec,
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
const first = digits(size, firstCandidates);
const second = digits(size, secondCandidates);
const accepts = (a: number, b: number): boolean => {
switch (specification.type) {
case "kropki":
return specification.kind === "white"
? Math.abs(a - b) === 1
: a === b * 2 || b === a * 2;
case "difference":
return Math.abs(a - b) === specification.difference;
case "ratio":
return a === b * specification.ratio || b === a * specification.ratio;
case "xv":
return a + b === specification.total;
case "inequality":
return specification.relation === "<" ? a < b : a > b;
}
};
const output: DigitPair[] = [];
for (const a of first)
for (const b of second) if (accepts(a, b)) output.push([a, b]);
return output;
}
export function kropkiPairs(
kind: "white" | "black",
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
return relationPairs(
{ type: "kropki", kind },
size,
firstCandidates,
secondCandidates,
);
}
export function xvPairs(
total: number,
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
return relationPairs(
{ type: "xv", total },
size,
firstCandidates,
secondCandidates,
);
}
export function inequalityPairs(
relation: "<" | ">",
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
return relationPairs(
{ type: "inequality", relation },
size,
firstCandidates,
secondCandidates,
);
}
+158
View File
@@ -0,0 +1,158 @@
import { analyzeKillerCage, type KillerAnalysis } from "./killer";
export interface ResidualOptions {
readonly total?: number;
readonly knownValues?: readonly number[];
readonly knownSums?: readonly number[];
readonly unknownCount?: number;
readonly size?: number;
readonly allowedDigits?: readonly number[];
readonly allowRepeats?: boolean;
}
export interface ResidualResult {
readonly total: number;
readonly accounted: number;
readonly residual: number;
readonly unknownCount?: number;
readonly analysis?: KillerAnalysis;
}
/** Generic arithmetic behind the 45 rule (also useful for non-9×9 boards). */
export function calculateResidual(options: ResidualOptions): ResidualResult {
const size = options.size ?? 9;
if (!Number.isInteger(size) || size < 1 || size > 25)
throw new RangeError("Invalid size.");
const total = options.total ?? (size * (size + 1)) / 2;
const parts = [...(options.knownValues ?? []), ...(options.knownSums ?? [])];
if (!Number.isFinite(total) || parts.some((part) => !Number.isFinite(part))) {
throw new RangeError("Totals and accounted parts must be finite numbers.");
}
const accounted = parts.reduce((sum, part) => sum + part, 0);
const residual = total - accounted;
if (options.unknownCount === undefined) return { total, accounted, residual };
if (
!Number.isInteger(options.unknownCount) ||
options.unknownCount < 0 ||
options.unknownCount > 25
) {
throw new RangeError("unknownCount must be an integer from 0 to 25.");
}
if (options.unknownCount === 0) {
return { total, accounted, residual, unknownCount: 0 };
}
return {
total,
accounted,
residual,
unknownCount: options.unknownCount,
analysis:
residual >= options.unknownCount &&
residual <= size * options.unknownCount
? analyzeKillerCage({
cellCount: options.unknownCount,
sum: residual,
size,
...(options.allowedDigits === undefined
? {}
: { allowedDigits: options.allowedDigits }),
...(options.allowRepeats === undefined
? {}
: { allowRepeats: options.allowRepeats }),
})
: {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from(
{ length: options.unknownCount },
() => [],
),
truncated: false,
},
};
}
export interface FortyFiveCage {
readonly cells: readonly number[];
readonly sum: number;
}
export interface FortyFiveRuleOptions {
readonly unitCells: readonly number[];
readonly cages?: readonly FortyFiveCage[];
readonly knownValues?: Readonly<Record<number, number>>;
readonly size?: number;
}
export interface FortyFiveRuleResult extends ResidualResult {
readonly accountedCells: readonly number[];
readonly residualCells: readonly number[];
readonly crossingCages: readonly FortyFiveCage[];
}
/**
* Subtract complete cages and uncovered solved cells from a Sudoku house. Cages
* crossing the boundary are reported but deliberately not subtracted.
*/
export function fortyFiveRuleResidual(
options: FortyFiveRuleOptions,
): FortyFiveRuleResult {
const size = options.size ?? 9;
const unit = [...new Set(options.unitCells)];
if (unit.length !== options.unitCells.length)
throw new RangeError("unitCells contains duplicates.");
if (
unit.some(
(cell) => !Number.isInteger(cell) || cell < 0 || cell >= size * size,
)
) {
throw new RangeError("unitCells contains a cell outside the board.");
}
const unitSet = new Set(unit);
const accountedCells = new Set<number>();
const cageSums: number[] = [];
const crossingCages: FortyFiveCage[] = [];
for (const cage of options.cages ?? []) {
const contained = cage.cells.every((cell) => unitSet.has(cell));
const touches = cage.cells.some((cell) => unitSet.has(cell));
if (touches && !contained) {
crossingCages.push({ cells: [...cage.cells], sum: cage.sum });
continue;
}
if (!contained) continue;
if (!Number.isFinite(cage.sum))
throw new RangeError("A cage sum is not finite.");
for (const cell of cage.cells) {
if (accountedCells.has(cell))
throw new RangeError("Complete cages overlap inside the unit.");
accountedCells.add(cell);
}
cageSums.push(cage.sum);
}
const knownValues: number[] = [];
for (const cell of unit) {
if (accountedCells.has(cell)) continue;
const value = options.knownValues?.[cell];
if (value === undefined || value === 0) continue;
if (!Number.isInteger(value) || value < 1 || value > size) {
throw new RangeError(`Known value for cell ${cell} is out of range.`);
}
accountedCells.add(cell);
knownValues.push(value);
}
const residualCells = unit.filter((cell) => !accountedCells.has(cell));
const residual = calculateResidual({
size,
knownSums: cageSums,
knownValues,
unknownCount: residualCells.length,
});
return {
...residual,
accountedCells: [...accountedCells].sort((a, b) => a - b),
residualCells,
crossingCages,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+187
View File
@@ -0,0 +1,187 @@
import {
PuzzleValidationError,
candidatesForCell,
compilePuzzle,
findConflicts,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type ValidationIssue,
} from "../domain";
import { seededRandom, shuffled, type RandomSource } from "./random";
export interface ExactSolveOptions {
readonly values?: readonly number[];
/** Stop after this many solutions. Defaults to 2 so uniqueness can be tested. */
readonly maxSolutions?: number;
readonly maxNodes?: number;
readonly timeoutMs?: number;
/** Randomizes equal choices deterministically when supplied. */
readonly seed?: string | number;
}
export type ExactLimitReason = "solution-cap" | "node-cap" | "timeout";
export interface ExactSolveResult {
readonly solutions: readonly (readonly number[])[];
readonly count: number;
readonly truncated: boolean;
readonly limitReason?: ExactLimitReason;
readonly nodes: number;
readonly elapsedMs: number;
}
function boundedInteger(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}.`,
);
}
return result;
}
function validateStart(
normalized: NormalizedPuzzle,
values: readonly number[],
): void {
const issues: ValidationIssue[] = [];
if (values.length !== normalized.size * normalized.size) {
issues.push({
path: "values",
message: `must contain exactly ${normalized.size ** 2} values`,
});
} else {
values.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > normalized.size) {
issues.push({
path: `values[${cell}]`,
message: `must be an integer from 0 to ${normalized.size}`,
});
}
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given) {
issues.push({
path: `values[${cell}]`,
message: "must preserve the given value",
});
}
});
}
if (issues.length === 0) {
for (const conflict of findConflicts(compilePuzzle(normalized), values)) {
issues.push({ path: "values", message: conflict.message });
}
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
export function solveExact(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: ExactSolveOptions = {},
): ExactSolveResult {
const normalized = normalizePuzzle(puzzle);
const board = [...(options.values ?? normalized.givens)];
validateStart(normalized, board);
const maxSolutions = boundedInteger(
options.maxSolutions,
2,
1,
100,
"maxSolutions",
);
const maxNodes = boundedInteger(
options.maxNodes,
2_000_000,
1,
100_000_000,
"maxNodes",
);
const timeoutMs = boundedInteger(
options.timeoutMs,
10_000,
1,
120_000,
"timeoutMs",
);
const compiled = compilePuzzle(normalized);
const random: RandomSource | undefined =
options.seed === undefined ? undefined : seededRandom(options.seed);
const solutions: number[][] = [];
const started = Date.now();
let nodes = 0;
let limitReason: ExactLimitReason | undefined;
const search = (): void => {
if (solutions.length >= maxSolutions) {
limitReason = "solution-cap";
return;
}
if (nodes >= maxNodes) {
limitReason = "node-cap";
return;
}
if (Date.now() - started >= timeoutMs) {
limitReason = "timeout";
return;
}
nodes += 1;
const tied: Array<readonly [number, number[]]> = [];
let minimum = normalized.size + 1;
for (let cell = 0; cell < board.length; cell += 1) {
if (board[cell] !== 0) continue;
const candidates = candidatesForCell(compiled, board, cell);
if (candidates.length === 0) return;
if (candidates.length < minimum) {
minimum = candidates.length;
tied.length = 0;
tied.push([cell, candidates]);
} else if (candidates.length === minimum) {
tied.push([cell, candidates]);
}
if (minimum === 1 && random === undefined) break;
}
if (tied.length === 0) {
solutions.push([...board]);
return;
}
const selection =
random === undefined ? tied[0] : tied[Math.floor(random() * tied.length)];
if (selection === undefined) return;
const chosen = selection[0];
let choices = selection[1];
if (random !== undefined) choices = shuffled(choices, random);
for (const value of choices) {
board[chosen] = value;
search();
board[chosen] = 0;
if (limitReason !== undefined) return;
}
};
search();
const result: ExactSolveResult = {
solutions,
count: solutions.length,
truncated: limitReason !== undefined,
nodes,
elapsedMs: Date.now() - started,
...(limitReason === undefined ? {} : { limitReason }),
};
return result;
}
export function countSolutions(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: Omit<ExactSolveOptions, "maxSolutions"> & {
readonly maxSolutions?: number;
} = {},
): number {
return solveExact(puzzle, options).count;
}
+207
View File
@@ -0,0 +1,207 @@
import {
classicRegions,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
} from "../domain";
import { solveExact } from "./exact";
import { seededRandom, shuffled } from "./random";
export type ClueSymmetry = "none" | "rotational";
export interface MinimizeOptions {
readonly seed?: string | number;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
}
export interface GenerateClassicOptions extends MinimizeOptions {
readonly size?: number;
readonly boxRows?: number;
readonly boxColumns?: number;
}
function factors(
size: number,
requestedRows?: number,
requestedColumns?: number,
): readonly [number, number] {
let rows = requestedRows;
let columns = requestedColumns;
if (rows === undefined && columns === undefined) {
rows = Math.floor(Math.sqrt(size));
while (rows > 1 && size % rows !== 0) rows -= 1;
columns = size / rows;
} else if (
rows === undefined &&
columns !== undefined &&
size % columns === 0
) {
rows = size / columns;
} else if (columns === undefined && rows !== undefined && size % rows === 0) {
columns = size / rows;
}
if (
rows === undefined ||
columns === undefined ||
!Number.isInteger(rows) ||
!Number.isInteger(columns) ||
rows * columns !== size
) {
throw new RangeError(
"boxRows and boxColumns must be factors whose product is size",
);
}
return [rows, columns];
}
function fullClassicGrid(
size: number,
boxRows: number,
boxColumns: number,
seed: string | number,
): number[] {
const random = seededRandom(seed);
const digits = shuffled(
Array.from({ length: size }, (_, index) => index + 1),
random,
);
const bandOrder = shuffled(
Array.from({ length: size / boxRows }, (_, index) => index),
random,
);
const rowOrder = bandOrder.flatMap((band) =>
shuffled(
Array.from({ length: boxRows }, (_, offset) => band * boxRows + offset),
random,
),
);
const stackOrder = shuffled(
Array.from({ length: size / boxColumns }, (_, index) => index),
random,
);
const columnOrder = stackOrder.flatMap((stack) =>
shuffled(
Array.from(
{ length: boxColumns },
(_, offset) => stack * boxColumns + offset,
),
random,
),
);
return rowOrder.flatMap((row) =>
columnOrder.map((column) => {
const pattern =
(row * boxColumns + Math.floor(row / boxRows) + column) % size;
return digits[pattern] as number;
}),
);
}
function boundedOption(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}`,
);
}
return result;
}
export function minimizePuzzle(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
): NormalizedPuzzle {
const normalized = normalizePuzzle(puzzle);
const targetClues = boundedOption(
options.targetClues,
Math.max(
normalized.size,
Math.round(normalized.size * normalized.size * 0.38),
),
0,
normalized.size * normalized.size,
"targetClues",
);
const maxChecks = boundedOption(
options.maxChecks,
normalized.size * normalized.size * 2,
1,
normalized.size * normalized.size * 10,
"maxChecks",
);
const symmetry = options.symmetry ?? "rotational";
if (symmetry !== "none" && symmetry !== "rotational") {
throw new RangeError('symmetry must be "none" or "rotational"');
}
const random = seededRandom(options.seed ?? "sudoku-tools");
const givens = [...normalized.givens];
const countClues = (): number =>
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
const order = shuffled(
Array.from({ length: givens.length }, (_, cell) => cell),
random,
);
let checks = 0;
for (const cell of order) {
if (checks >= maxChecks || countClues() <= targetClues) break;
if (givens[cell] === 0) continue;
const mirror = givens.length - cell - 1;
const group =
symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
if (group.some((entry) => givens[entry] === 0)) continue;
if (countClues() - group.length < targetClues) continue;
const saved = group.map((entry) => givens[entry] ?? 0);
group.forEach((entry) => {
givens[entry] = 0;
});
checks += 1;
const candidate: PuzzleDefinition = {
...normalized,
givens,
solution: normalized.solution,
};
const result = solveExact(candidate, {
maxSolutions: 2,
maxNodes: options.solveMaxNodes ?? 2_000_000,
timeoutMs: options.solveTimeoutMs ?? 10_000,
});
if (result.count !== 1 || result.truncated) {
group.forEach((entry, index) => {
givens[entry] = saved[index] ?? 0;
});
}
}
return normalizePuzzle({ ...normalized, givens });
}
export function generateClassic(
options: GenerateClassicOptions = {},
): NormalizedPuzzle {
const size = boundedOption(options.size, 9, 4, 16, "size");
const [boxRows, boxColumns] = factors(
size,
options.boxRows,
options.boxColumns,
);
const seed = options.seed ?? "sudoku-tools";
const solution = fullClassicGrid(size, boxRows, boxColumns, seed);
const full = normalizePuzzle({
version: 1,
size,
givens: solution,
solution,
regions: classicRegions(size, boxRows, boxColumns),
constraints: [],
});
return minimizePuzzle(full, options);
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./exact";
export * from "./generator";
export * from "./killer";
export * from "./logical";
+79
View File
@@ -0,0 +1,79 @@
export interface KillerCombinationOptions {
readonly size: number;
readonly count: number;
readonly sum: number;
readonly noRepeat?: boolean;
readonly usedDigits?: readonly number[];
readonly maxResults?: number;
}
export interface KillerCombinationResult {
readonly combinations: readonly (readonly number[])[];
readonly truncated: boolean;
}
export function killerDigitCombinations(
options: KillerCombinationOptions,
): KillerCombinationResult {
const { size, count, sum } = options;
if (!Number.isInteger(size) || size < 4 || size > 16) {
throw new RangeError("size must be an integer from 4 to 16");
}
if (!Number.isInteger(count) || count < 1 || count > size) {
throw new RangeError("count must be an integer from 1 to size");
}
if (!Number.isInteger(sum) || sum < count || sum > count * size) {
throw new RangeError("sum is outside the possible range");
}
const maxResults = options.maxResults ?? 10_000;
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 100_000) {
throw new RangeError("maxResults must be an integer from 1 to 100000");
}
const noRepeat = options.noRepeat !== false;
const usedDigits = new Set(options.usedDigits ?? []);
for (const digit of usedDigits) {
if (!Number.isInteger(digit) || digit < 1 || digit > size) {
throw new RangeError("usedDigits contains an out-of-range digit");
}
}
const combinations: number[][] = [];
let truncated = false;
const current: number[] = [];
const visit = (
remainingCount: number,
remainingSum: number,
minimum: number,
): void => {
if (combinations.length >= maxResults) {
truncated = true;
return;
}
if (remainingCount === 0) {
if (remainingSum === 0) combinations.push([...current]);
return;
}
const lowestPossible = noRepeat
? (remainingCount * (2 * minimum + remainingCount - 1)) / 2
: remainingCount * minimum;
const highestPossible = noRepeat
? (remainingCount * (2 * size - remainingCount + 1)) / 2
: remainingCount * size;
if (remainingSum < lowestPossible || remainingSum > highestPossible) return;
for (let digit = minimum; digit <= size; digit += 1) {
if (usedDigits.has(digit)) continue;
if (digit > remainingSum) break;
current.push(digit);
visit(
remainingCount - 1,
remainingSum - digit,
noRepeat ? digit + 1 : digit,
);
current.pop();
if (truncated) return;
}
};
visit(count, sum, 1);
return { combinations, truncated };
}
+728
View File
@@ -0,0 +1,728 @@
import {
PuzzleValidationError,
candidatesForCell,
compilePuzzle,
findConflicts,
isSolved,
normalizePuzzle,
type CellId,
type CompiledPuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type SudokuUnit,
type ValidationIssue,
} from "../domain";
export type LogicalTechnique =
| "naked-single"
| "hidden-single"
| "naked-pair"
| "naked-triple"
| "naked-quad"
| "hidden-pair"
| "hidden-triple"
| "hidden-quad"
| "pointing"
| "claiming"
| "x-wing"
| "swordfish"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
export interface LogicalPlacement {
readonly cell: CellId;
readonly value: number;
}
export interface LogicalElimination {
readonly cell: CellId;
readonly values: readonly number[];
}
export interface LogicalStep {
readonly technique: LogicalTechnique;
readonly placements: readonly LogicalPlacement[];
readonly eliminations: readonly LogicalElimination[];
readonly focusCells: readonly CellId[];
readonly explanation: string;
}
export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
readonly maxSteps?: number;
}
export interface LogicalSolveResult {
readonly status: LogicalSolveStatus;
readonly values: readonly number[];
readonly candidates: readonly (readonly number[])[];
readonly steps: readonly LogicalStep[];
}
interface LogicalState {
readonly compiled: CompiledPuzzle;
readonly values: number[];
readonly masks: number[];
}
function digitBit(value: number): number {
return 1 << value;
}
function popcount(mask: number): number {
let value = mask >>> 0;
let count = 0;
while (value !== 0) {
value &= value - 1;
count += 1;
}
return count;
}
function digits(mask: number, size: number): number[] {
const result: number[] = [];
for (let value = 1; value <= size; value += 1) {
if ((mask & digitBit(value)) !== 0) result.push(value);
}
return result;
}
function onlyDigit(mask: number, size: number): number {
return digits(mask, size)[0] ?? 0;
}
function combinations<T>(values: readonly T[], count: number): T[][] {
const result: T[][] = [];
const current: T[] = [];
const visit = (start: number): void => {
if (current.length === count) {
result.push([...current]);
return;
}
for (
let index = start;
index <= values.length - (count - current.length);
index += 1
) {
const value = values[index];
if (value === undefined) continue;
current.push(value);
visit(index + 1);
current.pop();
}
};
visit(0);
return result;
}
function validateStart(
normalized: NormalizedPuzzle,
values: readonly number[],
): void {
const issues: ValidationIssue[] = [];
if (values.length !== normalized.size * normalized.size) {
issues.push({
path: "values",
message: `must contain exactly ${normalized.size ** 2} values`,
});
} else {
values.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > normalized.size) {
issues.push({
path: `values[${cell}]`,
message: "contains an out-of-range value",
});
}
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given) {
issues.push({
path: `values[${cell}]`,
message: "must preserve the given value",
});
}
});
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
compiled,
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
return candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
}),
};
}
function eliminationStep(
technique: LogicalTechnique,
eliminations: readonly LogicalElimination[],
focusCells: readonly CellId[],
explanation: string,
): LogicalStep | undefined {
return eliminations.length === 0
? undefined
: { technique, placements: [], eliminations, focusCells, explanation };
}
function findNakedSingle(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let cell = 0; cell < state.values.length; cell += 1) {
const mask = state.masks[cell] ?? 0;
if (state.values[cell] === 0 && popcount(mask) === 1) {
const value = onlyDigit(mask, size);
return {
technique: "naked-single",
placements: [{ cell, value }],
eliminations: [],
focusCells: [cell],
explanation: `Cell ${cell + 1} has only one candidate: ${value}.`,
};
}
}
return undefined;
}
function findHiddenSingle(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const unit of state.compiled.units) {
for (let value = 1; value <= size; value += 1) {
if (unit.cells.some((cell) => state.values[cell] === value)) continue;
const cells = unit.cells.filter(
(cell) =>
state.values[cell] === 0 &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length === 1) {
const cell = cells[0] as number;
return {
technique: "hidden-single",
placements: [{ cell, value }],
eliminations: [],
focusCells: unit.cells,
explanation: `${value} has only one possible cell in this ${unit.kind}.`,
};
}
}
}
return undefined;
}
function subsetName(hidden: boolean, count: number): LogicalTechnique {
const suffix = count === 2 ? "pair" : count === 3 ? "triple" : "quad";
return `${hidden ? "hidden" : "naked"}-${suffix}` as LogicalTechnique;
}
function findNakedSubset(state: LogicalState): LogicalStep | undefined {
for (const unit of state.compiled.units) {
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
for (let count = 2; count <= 4; count += 1) {
const eligible = empty.filter((cell) => {
const total = popcount(state.masks[cell] ?? 0);
return total >= 2 && total <= count;
});
for (const cells of combinations(eligible, count)) {
const union = cells.reduce(
(mask, cell) => mask | (state.masks[cell] ?? 0),
0,
);
if (popcount(union) !== count) continue;
const selected = new Set(cells);
const eliminations = empty
.filter(
(cell) =>
!selected.has(cell) && ((state.masks[cell] ?? 0) & union) !== 0,
)
.map((cell) => ({
cell,
values: digits(
(state.masks[cell] ?? 0) & union,
state.compiled.puzzle.size,
),
}));
const step = eliminationStep(
subsetName(false, count),
eliminations,
cells,
`${count} cells contain only the same ${count} candidates in this ${unit.kind}.`,
);
if (step !== undefined) return step;
}
}
}
return undefined;
}
function findHiddenSubset(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
const values = Array.from({ length: size }, (_, index) => index + 1);
for (const unit of state.compiled.units) {
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
for (let count = 2; count <= 4; count += 1) {
for (const selectedValues of combinations(values, count)) {
const subsetMask = selectedValues.reduce(
(mask, value) => mask | digitBit(value),
0,
);
if (
selectedValues.some(
(value) =>
!empty.some(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
),
)
) {
continue;
}
const cells = empty.filter(
(cell) => ((state.masks[cell] ?? 0) & subsetMask) !== 0,
);
if (cells.length !== count) continue;
const eliminations = cells
.filter((cell) => ((state.masks[cell] ?? 0) & ~subsetMask) !== 0)
.map((cell) => ({
cell,
values: digits((state.masks[cell] ?? 0) & ~subsetMask, size),
}));
const step = eliminationStep(
subsetName(true, count),
eliminations,
cells,
`${selectedValues.join(", ")} can occur only in ${count} cells of this ${unit.kind}.`,
);
if (step !== undefined) return step;
}
}
}
return undefined;
}
function findPointingOrClaiming(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
const regions = state.compiled.units.filter((unit) => unit.kind === "region");
for (const region of regions) {
const regionSet = new Set(region.cells);
for (let value = 1; value <= size; value += 1) {
const cells = region.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length < 2) continue;
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
const columns = new Set(cells.map((cell) => cell % size));
const aligned =
rows.size === 1
? (["row", [...rows][0]] as const)
: columns.size === 1
? (["column", [...columns][0]] as const)
: undefined;
if (aligned === undefined || aligned[1] === undefined) continue;
const unit = state.compiled.units.find(
(candidate) =>
candidate.kind === aligned[0] && candidate.index === aligned[1],
);
if (unit === undefined) continue;
const eliminations = unit.cells
.filter(
(cell) =>
!regionSet.has(cell) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"pointing",
eliminations,
cells,
`${value} is confined to one ${aligned[0]} inside a region.`,
);
if (step !== undefined) return step;
}
}
const lines = state.compiled.units.filter(
(unit) => unit.kind === "row" || unit.kind === "column",
);
for (const line of lines) {
for (let value = 1; value <= size; value += 1) {
const cells = line.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length < 2) continue;
const regionIds = new Set(
cells.map((cell) => state.compiled.puzzle.regions[cell]),
);
if (regionIds.size !== 1) continue;
const regionId = [...regionIds][0];
const region = regions.find((unit) => unit.index === regionId);
if (region === undefined) continue;
const lineSet = new Set(line.cells);
const eliminations = region.cells
.filter(
(cell) =>
!lineSet.has(cell) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"claiming",
eliminations,
cells,
`${value} in this ${line.kind} is confined to a single region.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function lineUnit(
compiled: CompiledPuzzle,
kind: "row" | "column",
index: number,
): SudokuUnit | undefined {
return compiled.units.find(
(unit) => unit.kind === kind && unit.index === index,
);
}
function findFish(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const count of [2, 3]) {
for (const [baseKind, coverKind] of [
["row", "column"],
["column", "row"],
] as const) {
for (let value = 1; value <= size; value += 1) {
const eligible = Array.from(
{ length: size },
(_, index) => index,
).filter((index) => {
const unit = lineUnit(state.compiled, baseKind, index);
const total =
unit?.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
).length ?? 0;
return total >= 2 && total <= count;
});
for (const baseIndices of combinations(eligible, count)) {
const coverIndices = new Set<number>();
const focus: number[] = [];
for (const baseIndex of baseIndices) {
const unit = lineUnit(state.compiled, baseKind, baseIndex);
for (const cell of unit?.cells ?? []) {
if (((state.masks[cell] ?? 0) & digitBit(value)) === 0) continue;
focus.push(cell);
coverIndices.add(
coverKind === "column" ? cell % size : Math.floor(cell / size),
);
}
}
if (coverIndices.size !== count) continue;
const baseSet = new Set(baseIndices);
const eliminations: LogicalElimination[] = [];
for (const coverIndex of coverIndices) {
const unit = lineUnit(state.compiled, coverKind, coverIndex);
for (const cell of unit?.cells ?? []) {
const baseIndex =
baseKind === "row" ? Math.floor(cell / size) : cell % size;
if (
!baseSet.has(baseIndex) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0
) {
eliminations.push({ cell, values: [value] });
}
}
}
const technique: LogicalTechnique =
count === 2 ? "x-wing" : "swordfish";
const step = eliminationStep(
technique,
eliminations,
focus,
`${value} forms a ${technique} across ${count} ${baseKind}s.`,
);
if (step !== undefined) return step;
}
}
}
}
return undefined;
}
function commonPeers(
compiled: CompiledPuzzle,
cells: readonly CellId[],
): Set<CellId> {
const first = cells[0];
if (first === undefined) return new Set();
const result = new Set(compiled.peers[first]);
for (const cell of cells.slice(1)) {
for (const candidate of result) {
if (!compiled.peers[cell]?.has(candidate)) result.delete(candidate);
}
}
for (const cell of cells) result.delete(cell);
return result;
}
function findXyWing(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
const pivotMask = state.masks[pivot] ?? 0;
if (popcount(pivotMask) !== 2) continue;
const wings = [...(state.compiled.peers[pivot] ?? [])].filter(
(cell) => popcount(state.masks[cell] ?? 0) === 2,
);
for (const pair of combinations(wings, 2)) {
const a = pair[0];
const b = pair[1];
if (a === undefined || b === undefined) continue;
const aMask = state.masks[a] ?? 0;
const bMask = state.masks[b] ?? 0;
const sharedA = aMask & pivotMask;
const sharedB = bMask & pivotMask;
if (
popcount(sharedA) !== 1 ||
popcount(sharedB) !== 1 ||
sharedA === sharedB
)
continue;
const zMask = aMask & bMask & ~pivotMask;
if (popcount(zMask) !== 1) continue;
const value = onlyDigit(zMask, size);
const eliminations = [...commonPeers(state.compiled, [a, b])]
.filter((cell) => ((state.masks[cell] ?? 0) & zMask) !== 0)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"xy-wing",
eliminations,
[pivot, a, b],
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XY-Wing eliminating ${value}.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function findXyzWing(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
const pivotMask = state.masks[pivot] ?? 0;
if (popcount(pivotMask) !== 3) continue;
const wings = [...(state.compiled.peers[pivot] ?? [])].filter((cell) => {
const mask = state.masks[cell] ?? 0;
return popcount(mask) === 2 && (mask & ~pivotMask) === 0;
});
for (const pair of combinations(wings, 2)) {
const a = pair[0];
const b = pair[1];
if (a === undefined || b === undefined) continue;
const aMask = state.masks[a] ?? 0;
const bMask = state.masks[b] ?? 0;
if ((aMask | bMask) !== pivotMask) continue;
const shared = aMask & bMask;
if (popcount(shared) !== 1) continue;
const value = onlyDigit(shared, size);
const eliminations = [...commonPeers(state.compiled, [pivot, a, b])]
.filter((cell) => ((state.masks[cell] ?? 0) & shared) !== 0)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"xyz-wing",
eliminations,
[pivot, a, b],
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XYZ-Wing eliminating ${value}.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function findKillerReduction(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const constraint of state.compiled.puzzle.constraints) {
if (constraint.type !== "killer-cage") continue;
const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
if (empty.length === 0) continue;
const assigned = constraint.cells
.map((cell) => state.values[cell] ?? 0)
.filter((value) => value !== 0);
const target =
constraint.sum - assigned.reduce((sum, value) => sum + value, 0);
const used = new Set(assigned);
const allowed = new Map<CellId, number>();
empty.forEach((cell) => allowed.set(cell, 0));
let visits = 0;
let truncated = false;
const chosen = new Set<number>(used);
const visit = (index: number, remaining: number): void => {
if (visits >= 100_000) {
truncated = true;
return;
}
visits += 1;
if (index === empty.length) {
if (remaining === 0) {
empty.forEach((cell) => {
const value = state.values[cell] ?? 0;
// Temporary chosen values are stored just beyond the live board.
const selected = assignment[indexByCell.get(cell) ?? -1] ?? value;
allowed.set(cell, (allowed.get(cell) ?? 0) | digitBit(selected));
});
}
return;
}
const cell = empty[index];
if (cell === undefined) return;
const remainingCells = empty.length - index - 1;
for (const value of digits(state.masks[cell] ?? 0, size)) {
if (constraint.noRepeat !== false && chosen.has(value)) continue;
const next = remaining - value;
if (next < remainingCells || next > remainingCells * size) continue;
assignment[index] = value;
chosen.add(value);
visit(index + 1, next);
chosen.delete(value);
}
};
const assignment = new Array<number>(empty.length).fill(0);
const indexByCell = new Map(empty.map((cell, index) => [cell, index]));
visit(0, target);
if (truncated) continue;
const eliminations = empty
.filter(
(cell) => ((state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0)) !== 0,
)
.map((cell) => ({
cell,
values: digits(
(state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0),
size,
),
}));
const step = eliminationStep(
"killer-cage",
eliminations,
constraint.cells,
`Only sum-compatible assignments remain in the ${constraint.sum} cage.`,
);
if (step !== undefined) return step;
}
return undefined;
}
function findStep(state: LogicalState): LogicalStep | undefined {
return (
findNakedSingle(state) ??
findHiddenSingle(state) ??
findNakedSubset(state) ??
findHiddenSubset(state) ??
findPointingOrClaiming(state) ??
findFish(state) ??
findXyWing(state) ??
findXyzWing(state) ??
findKillerReduction(state)
);
}
function applyStep(state: LogicalState, step: LogicalStep): void {
for (const elimination of step.eliminations) {
let mask = state.masks[elimination.cell] ?? 0;
for (const value of elimination.values) mask &= ~digitBit(value);
state.masks[elimination.cell] = mask;
}
for (const placement of step.placements) {
state.values[placement.cell] = placement.value;
state.masks[placement.cell] = 0;
}
if (step.placements.length > 0) {
for (let cell = 0; cell < state.values.length; cell += 1) {
if (state.values[cell] !== 0) continue;
const raw = candidatesForCell(state.compiled, state.values, cell).reduce(
(mask, value) => mask | digitBit(value),
0,
);
state.masks[cell] = (state.masks[cell] ?? 0) & raw;
}
}
}
function exposedCandidates(
state: LogicalState,
): readonly (readonly number[])[] {
return state.masks.map((mask) => digits(mask, state.compiled.puzzle.size));
}
export function solveLogically(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: LogicalSolveOptions = {},
): LogicalSolveResult {
const normalized = normalizePuzzle(puzzle);
const values = options.values ?? normalized.givens;
validateStart(normalized, values);
const maxSteps = options.maxSteps ?? 1_000;
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
throw new RangeError("maxSteps must be an integer from 1 to 10000");
}
const state = initializeState(normalized, values);
const steps: LogicalStep[] = [];
if (findConflicts(state.compiled, state.values).length > 0) {
return {
status: "invalid",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
while (steps.length < maxSteps) {
if (isSolved(state.compiled, state.values)) {
return {
status: "solved",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
if (
state.values.some(
(value, cell) => value === 0 && (state.masks[cell] ?? 0) === 0,
)
) {
return {
status: "invalid",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
const step = findStep(state);
if (step === undefined) {
return {
status: "stuck",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
applyStep(state, step);
steps.push(step);
}
return {
status: isSolved(state.compiled, state.values) ? "solved" : "step-limit",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
+33
View File
@@ -0,0 +1,33 @@
export type RandomSource = () => number;
function hashSeed(seed: string | number): number {
const text = String(seed);
let hash = 2_166_136_261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return hash >>> 0;
}
export function seededRandom(seed: string | number): RandomSource {
let state = hashSeed(seed);
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
};
}
export function shuffled<T>(values: readonly T[], random: RandomSource): T[] {
const result = [...values];
for (let index = result.length - 1; index > 0; index -= 1) {
const other = Math.floor(random() * (index + 1));
const value = result[index];
result[index] = result[other] as T;
result[other] = value as T;
}
return result;
}
+122
View File
@@ -0,0 +1,122 @@
export type EntryMode = "value" | "corner" | "center" | "color";
export interface PlaySnapshot {
values: number[];
cornerMarks: number[];
centerMarks: number[];
colors: number[];
}
export interface PlaySession extends PlaySnapshot {
elapsedSeconds: number;
paused: boolean;
}
export function createSession(givens: readonly number[]): PlaySession {
return {
values: [...givens],
cornerMarks: givens.map(() => 0),
centerMarks: givens.map(() => 0),
colors: givens.map(() => 0),
elapsedSeconds: 0,
paused: false,
};
}
export function snapshotSession(session: PlaySession): PlaySnapshot {
return {
values: [...session.values],
cornerMarks: [...session.cornerMarks],
centerMarks: [...session.centerMarks],
colors: [...session.colors],
};
}
export function restoreSnapshot(
session: PlaySession,
snapshot: PlaySnapshot,
): PlaySession {
return {
...session,
values: [...snapshot.values],
cornerMarks: [...snapshot.cornerMarks],
centerMarks: [...snapshot.centerMarks],
colors: [...snapshot.colors],
};
}
function toggleMask(mask: number, value: number) {
const bit = 1 << (value - 1);
return mask & bit ? mask & ~bit : mask | bit;
}
export function enterSelection(
session: PlaySession,
selection: ReadonlySet<number>,
mode: EntryMode,
value: number,
givens: readonly number[],
): PlaySession {
const next = restoreSnapshot(session, session);
for (const cell of selection) {
if (cell < 0 || cell >= next.values.length) continue;
if (mode === "value") {
if (givens[cell]) continue;
next.values[cell] = next.values[cell] === value ? 0 : value;
next.cornerMarks[cell] = 0;
next.centerMarks[cell] = 0;
} else if (mode === "corner") {
if (next.values[cell]) continue;
next.cornerMarks[cell] = toggleMask(next.cornerMarks[cell] ?? 0, value);
} else if (mode === "center") {
if (next.values[cell]) continue;
next.centerMarks[cell] = toggleMask(next.centerMarks[cell] ?? 0, value);
} else {
next.colors[cell] = next.colors[cell] === value ? 0 : value;
}
}
return next;
}
export function eraseSelection(
session: PlaySession,
selection: ReadonlySet<number>,
mode: EntryMode,
givens: readonly number[],
): PlaySession {
const next = restoreSnapshot(session, session);
for (const cell of selection) {
if (cell < 0 || cell >= next.values.length) continue;
if (mode === "value" && !givens[cell]) next.values[cell] = 0;
if (mode === "corner") next.cornerMarks[cell] = 0;
if (mode === "center") next.centerMarks[cell] = 0;
if (mode === "color") next.colors[cell] = 0;
}
return next;
}
export function maskValues(mask: number, size: number): number[] {
const values: number[] = [];
for (let value = 1; value <= size; value += 1)
if (mask & (1 << (value - 1))) values.push(value);
return values;
}
export function symbolFor(value: number, size: number) {
if (value <= 0) return "";
if (value <= 9) return String(value);
if (size <= 16) return String.fromCharCode(55 + value);
return String(value);
}
export function valueForKey(key: string, size: number) {
if (/^[1-9]$/u.test(key)) {
const value = Number(key);
return value <= size ? value : null;
}
if (/^[a-z]$/iu.test(key)) {
const value = key.toUpperCase().charCodeAt(0) - 55;
return value >= 10 && value <= size ? value : null;
}
return null;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./library";
export * from "./record";
export * from "./types";
+314
View File
@@ -0,0 +1,314 @@
import { SudokuFormatError } from "../formats";
import {
cloneProjectRecord,
MAX_PROJECT_BYTES,
normalizeProjectRecord,
} from "./record";
import type {
ProjectLibraryExport,
SudokuProjectRecord,
SudokuProjectSummary,
} from "./types";
export const MAX_LIBRARY_PROJECTS = 256;
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
const DATABASE_VERSION = 1;
const STORE_NAME = "projects";
export type ProjectLibraryMode = "indexeddb" | "memory";
export interface ProjectLibraryOptions {
readonly indexedDB?: IDBFactory | null;
readonly databaseName?: string;
}
function summary(record: SudokuProjectRecord): SudokuProjectSummary {
return {
id: record.id,
title: record.title,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
size: record.puzzle.size,
completed: record.progress?.completed ?? false,
};
}
function bytes(record: SudokuProjectRecord): number {
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error("IndexedDB request failed."));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
transaction.onabort = () =>
reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
});
}
async function openDatabase(
factory: IDBFactory,
name: string,
): Promise<IDBDatabase> {
return await new Promise((resolve, reject) => {
const request = factory.open(name, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
store.createIndex("updatedAt", "updatedAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error("Could not open IndexedDB."));
request.onblocked = () =>
reject(new Error("The project library database upgrade is blocked."));
});
}
export class ProjectLibrary {
readonly #factory: IDBFactory | null;
readonly #databaseName: string;
readonly #memory = new Map<string, SudokuProjectRecord>();
#database: Promise<IDBDatabase> | undefined;
#mode: ProjectLibraryMode;
constructor(options: ProjectLibraryOptions = {}) {
this.#factory =
options.indexedDB === undefined
? typeof indexedDB === "undefined"
? null
: indexedDB
: options.indexedDB;
this.#databaseName = options.databaseName ?? "sudoku-tools";
this.#mode = this.#factory === null ? "memory" : "indexeddb";
}
get mode(): ProjectLibraryMode {
return this.#mode;
}
async ready(): Promise<ProjectLibraryMode> {
await this.#db();
return this.#mode;
}
async #db(): Promise<IDBDatabase | undefined> {
if (this.#mode === "memory" || this.#factory === null) return undefined;
this.#database ??= openDatabase(this.#factory, this.#databaseName);
try {
return await this.#database;
} catch {
this.#mode = "memory";
this.#database = undefined;
return undefined;
}
}
#memoryTotal(replacement?: SudokuProjectRecord): number {
let total = 0;
for (const record of this.#memory.values()) {
if (replacement !== undefined && record.id === replacement.id) continue;
total += bytes(record);
}
return total + (replacement === undefined ? 0 : bytes(replacement));
}
#memoryPut(record: SudokuProjectRecord): void {
if (
!this.#memory.has(record.id) &&
this.#memory.size >= MAX_LIBRARY_PROJECTS
) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
`The fallback library is limited to ${MAX_LIBRARY_PROJECTS} projects.`,
);
}
if (this.#memoryTotal(record) > MAX_MEMORY_LIBRARY_BYTES) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The in-memory project library is full.",
);
}
this.#memory.set(record.id, cloneProjectRecord(record));
}
async list(): Promise<readonly SudokuProjectSummary[]> {
const database = await this.#db();
if (database === undefined) {
return [...this.#memory.values()]
.map(summary)
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
}
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const records = await requestResult(
transaction.objectStore(STORE_NAME).getAll(),
);
await transactionDone(transaction);
if (records.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The project library contains too many records.",
);
}
return records
.map((record) => summary(normalizeProjectRecord(record)))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.list();
}
}
async get(id: string): Promise<SudokuProjectRecord | undefined> {
const database = await this.#db();
if (database === undefined) {
const record = this.#memory.get(id);
return record === undefined ? undefined : cloneProjectRecord(record);
}
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const value = await requestResult(
transaction.objectStore(STORE_NAME).get(id),
);
await transactionDone(transaction);
return value === undefined ? undefined : normalizeProjectRecord(value);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.get(id);
}
}
async put(value: SudokuProjectRecord): Promise<SudokuProjectRecord> {
const record = normalizeProjectRecord(value);
if (bytes(record) > MAX_PROJECT_BYTES) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The project is too large to save.",
);
}
const database = await this.#db();
if (database === undefined) {
this.#memoryPut(record);
return cloneProjectRecord(record);
}
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
const existing = await requestResult(store.getKey(record.id));
const count = await requestResult(store.count());
if (existing === undefined && count >= MAX_LIBRARY_PROJECTS) {
transaction.abort();
throw new SudokuFormatError(
"STORAGE_LIMIT",
`The project library is limited to ${MAX_LIBRARY_PROJECTS} projects.`,
);
}
store.put(record);
await transactionDone(transaction);
return cloneProjectRecord(record);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
this.#memoryPut(record);
return cloneProjectRecord(record);
}
}
async delete(id: string): Promise<boolean> {
const database = await this.#db();
if (database === undefined) return this.#memory.delete(id);
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
const exists = (await requestResult(store.getKey(id))) !== undefined;
if (exists) store.delete(id);
await transactionDone(transaction);
return exists;
} catch {
this.#mode = "memory";
return this.#memory.delete(id);
}
}
async clear(): Promise<void> {
const database = await this.#db();
if (database === undefined) {
this.#memory.clear();
return;
}
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).clear();
await transactionDone(transaction);
} catch {
this.#mode = "memory";
this.#memory.clear();
}
}
async exportAll(): Promise<ProjectLibraryExport> {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
for (const item of summaries) {
const record = await this.get(item.id);
if (record !== undefined) projects.push(record);
}
return {
schema: "de.add-ideas.sudoku-tools.library",
version: 1,
exportedAt: Date.now(),
projects,
};
}
async importAll(value: unknown, replace = false): Promise<number> {
if (
typeof value !== "object" ||
value === null ||
(value as { schema?: unknown }).schema !==
"de.add-ideas.sudoku-tools.library" ||
(value as { version?: unknown }).version !== 1 ||
!Array.isArray((value as { projects?: unknown }).projects)
) {
throw new SudokuFormatError(
"INVALID_LIBRARY",
"Unsupported project library export.",
);
}
const raw = (value as { projects: unknown[] }).projects;
if (raw.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The import contains too many projects.",
);
}
const records = raw.map(normalizeProjectRecord);
if (replace) await this.clear();
for (const record of records) await this.put(record);
return records.length;
}
}
export function createProjectLibrary(
options?: ProjectLibraryOptions,
): ProjectLibrary {
return new ProjectLibrary(options);
}
+280
View File
@@ -0,0 +1,280 @@
import {
MAX_DOCUMENT_BYTES,
SudokuFormatError,
cloneSudokuDocument,
normalizeSudokuDocument,
} from "../formats";
import {
PROJECT_RECORD_SCHEMA,
PROJECT_RECORD_VERSION,
type SudokuProgress,
type SudokuProjectRecord,
} from "./types";
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
export const MAX_PROJECT_ID_LENGTH = 128;
export const MAX_PROJECT_TITLE_LENGTH = 500;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function storageError(code: string, message: string): never {
throw new SudokuFormatError(code, message);
}
function timestamp(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return storageError(
"INVALID_PROJECT",
`${label} must be a non-negative timestamp.`,
);
}
return value;
}
function progress(value: unknown, size: number): SudokuProgress | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || value.version !== 1) {
return storageError("INVALID_PROJECT", "Unsupported progress record.");
}
const cellCount = size * size;
if (!Array.isArray(value.values) || value.values.length !== cellCount) {
return storageError(
"INVALID_PROJECT",
`Progress values must contain ${cellCount} entries.`,
);
}
const values = value.values.map((entry, index) => {
if (
!Number.isInteger(entry) ||
(entry as number) < 0 ||
(entry as number) > size
) {
return storageError(
"INVALID_PROJECT",
`Progress value ${index + 1} is out of range.`,
);
}
return entry as number;
});
const marks = (input: unknown, label: string): number[][] | undefined => {
if (input === undefined) return undefined;
if (!Array.isArray(input) || input.length !== cellCount) {
return storageError(
"INVALID_PROJECT",
`Progress ${label} must contain ${cellCount} entries.`,
);
}
return input.map((entry, index) => {
if (!Array.isArray(entry) || entry.length > size) {
return storageError(
"INVALID_PROJECT",
`Progress ${label} for cell ${index + 1} are invalid.`,
);
}
const result = entry.map((digit) => {
if (
!Number.isInteger(digit) ||
(digit as number) < 1 ||
(digit as number) > size
) {
return storageError(
"INVALID_PROJECT",
`A ${label} value for cell ${index + 1} is out of range.`,
);
}
return digit as number;
});
return [...new Set(result)].sort((a, b) => a - b);
});
};
const candidates = marks(value.candidates, "candidates");
const cornerMarks = marks(value.cornerMarks, "corner marks");
const centerMarks = marks(value.centerMarks, "center marks") ?? candidates;
let colors: number[] | undefined;
if (value.colors !== undefined) {
if (!Array.isArray(value.colors) || value.colors.length !== cellCount) {
return storageError(
"INVALID_PROJECT",
`Progress colors must contain ${cellCount} entries.`,
);
}
colors = value.colors.map((color, index) => {
if (
!Number.isInteger(color) ||
(color as number) < 0 ||
(color as number) > 8
) {
return storageError(
"INVALID_PROJECT",
`Progress color for cell ${index + 1} is out of range.`,
);
}
return color as number;
});
}
if (
value.elapsedMs !== undefined &&
(!Number.isFinite(value.elapsedMs) || (value.elapsedMs as number) < 0)
) {
return storageError(
"INVALID_PROJECT",
"elapsedMs must be a non-negative number.",
);
}
if (value.completed !== undefined && typeof value.completed !== "boolean") {
return storageError("INVALID_PROJECT", "completed must be true or false.");
}
return {
version: 1,
values,
...(cornerMarks === undefined ? {} : { cornerMarks }),
...(centerMarks === undefined ? {} : { centerMarks }),
...(colors === undefined ? {} : { colors }),
...(candidates === undefined ? {} : { candidates }),
...(value.elapsedMs === undefined
? {}
: { elapsedMs: value.elapsedMs as number }),
...(value.completed === undefined ? {} : { completed: value.completed }),
};
}
export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
if (!isRecord(value))
return storageError("INVALID_PROJECT", "A project must be an object.");
if (
value.schema !== PROJECT_RECORD_SCHEMA ||
value.version !== PROJECT_RECORD_VERSION
) {
return storageError(
"UNSUPPORTED_VERSION",
"Unsupported project record schema or version.",
);
}
if (
typeof value.id !== "string" ||
value.id.length === 0 ||
value.id.length > MAX_PROJECT_ID_LENGTH ||
[...value.id].some((character) => (character.codePointAt(0) ?? 0) <= 31)
) {
return storageError(
"INVALID_PROJECT",
"The project ID is empty, too long, or unsafe.",
);
}
if (
typeof value.title !== "string" ||
value.title.length > MAX_PROJECT_TITLE_LENGTH
) {
return storageError(
"INVALID_PROJECT",
"The project title is invalid or too long.",
);
}
const createdAt = timestamp(value.createdAt, "createdAt");
const updatedAt = timestamp(value.updatedAt, "updatedAt");
if (updatedAt < createdAt) {
return storageError(
"INVALID_PROJECT",
"updatedAt cannot precede createdAt.",
);
}
const puzzle = normalizeSudokuDocument(value.puzzle);
const normalized: SudokuProjectRecord = {
schema: PROJECT_RECORD_SCHEMA,
version: PROJECT_RECORD_VERSION,
id: value.id,
title: value.title,
createdAt,
updatedAt,
puzzle,
...(value.progress === undefined
? {}
: { progress: progress(value.progress, puzzle.size) }),
};
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
if (bytes > MAX_PROJECT_BYTES) {
return storageError(
"LIMIT_EXCEEDED",
`The project exceeds ${MAX_PROJECT_BYTES.toLocaleString()} bytes.`,
);
}
return normalized;
}
export interface NewProjectOptions {
readonly id?: string;
readonly title?: string;
readonly now?: number;
readonly progress?: SudokuProgress;
}
function randomId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
}
export function createProjectRecord(
puzzle: SudokuProjectRecord["puzzle"],
options: NewProjectOptions = {},
): SudokuProjectRecord {
const now = options.now ?? Date.now();
return normalizeProjectRecord({
schema: PROJECT_RECORD_SCHEMA,
version: PROJECT_RECORD_VERSION,
id: options.id ?? randomId(),
title: options.title ?? puzzle.title ?? "Untitled puzzle",
createdAt: now,
updatedAt: now,
puzzle,
...(options.progress === undefined ? {} : { progress: options.progress }),
});
}
export function cloneProjectRecord(
record: SudokuProjectRecord,
): SudokuProjectRecord {
const normalized = normalizeProjectRecord(record);
return {
...normalized,
puzzle: cloneSudokuDocument(normalized.puzzle),
...(normalized.progress === undefined
? {}
: {
progress: {
...normalized.progress,
values: [...normalized.progress.values],
...(normalized.progress.cornerMarks === undefined
? {}
: {
cornerMarks: normalized.progress.cornerMarks.map((entry) => [
...entry,
]),
}),
...(normalized.progress.centerMarks === undefined
? {}
: {
centerMarks: normalized.progress.centerMarks.map((entry) => [
...entry,
]),
}),
...(normalized.progress.colors === undefined
? {}
: { colors: [...normalized.progress.colors] }),
...(normalized.progress.candidates === undefined
? {}
: {
candidates: normalized.progress.candidates.map((entry) => [
...entry,
]),
}),
},
}),
};
}
+44
View File
@@ -0,0 +1,44 @@
import type { SudokuDocument } from "../formats";
export const PROJECT_RECORD_SCHEMA =
"de.add-ideas.sudoku-tools.project" as const;
export const PROJECT_RECORD_VERSION = 1 as const;
export interface SudokuProgress {
readonly version: 1;
readonly values: readonly number[];
readonly cornerMarks?: readonly (readonly number[])[];
readonly centerMarks?: readonly (readonly number[])[];
readonly colors?: readonly number[];
/** Legacy alias for centerMarks in early v1 records. */
readonly candidates?: readonly (readonly number[])[];
readonly elapsedMs?: number;
readonly completed?: boolean;
}
export interface SudokuProjectRecord {
readonly schema: typeof PROJECT_RECORD_SCHEMA;
readonly version: typeof PROJECT_RECORD_VERSION;
readonly id: string;
readonly title: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly puzzle: SudokuDocument;
readonly progress?: SudokuProgress;
}
export interface SudokuProjectSummary {
readonly id: string;
readonly title: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly size: number;
readonly completed: boolean;
}
export interface ProjectLibraryExport {
readonly schema: "de.add-ideas.sudoku-tools.library";
readonly version: 1;
readonly exportedAt: number;
readonly projects: readonly SudokuProjectRecord[];
}
+2176
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
if (
!(
HTMLDialogElement.prototype as HTMLDialogElement & {
showModal?: () => void;
}
).showModal
) {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute("open", "");
};
}
const nativeDialogClose = HTMLDialogElement.prototype.close;
HTMLDialogElement.prototype.close = function close(returnValue?: string) {
if (nativeDialogClose) {
try {
nativeDialogClose.call(this, returnValue);
return;
} catch {
// jsdom fallback.
}
}
this.removeAttribute("open");
};
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:sudoku-tools-test"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
"version": "0.1.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["games", "puzzles", "logic"],
"tags": ["sudoku", "killer", "solver", "setter", "generator", "candidates"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": true,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false,
"label": "Puzzles and progress stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/sudoku-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/sudoku-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APPLICATION_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+127
View File
@@ -0,0 +1,127 @@
import type {
SolverWorkerOperation,
SolverWorkerRequest,
SolverWorkerResponse,
SolverWorkerValue,
} from "./protocol";
export interface SolverWorkerClient {
readonly request: (
operation: SolverWorkerOperation,
options?: {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
/** Restart the worker and reject older work. Defaults to true. */
readonly supersede?: boolean;
},
) => Promise<SolverWorkerValue>;
readonly terminate: () => void;
}
interface PendingRequest {
readonly resolve: (value: SolverWorkerValue) => void;
readonly reject: (reason: unknown) => void;
readonly timer: ReturnType<typeof setTimeout>;
readonly removeAbort: () => void;
}
export function createSolverWorkerClient(): SolverWorkerClient {
const pending = new Map<string, PendingRequest>();
let sequence = 0;
let terminated = false;
const rejectPending = (reason: Error): void => {
for (const entry of pending.values()) {
clearTimeout(entry.timer);
entry.removeAbort();
entry.reject(reason);
}
pending.clear();
};
const spawnWorker = (): Worker => {
const next = new Worker(new URL("./solver.worker.ts", import.meta.url), {
type: "module",
});
next.addEventListener(
"message",
(event: MessageEvent<SolverWorkerResponse>) => {
const response = event.data;
const entry = pending.get(response.id);
if (entry === undefined) return;
pending.delete(response.id);
clearTimeout(entry.timer);
entry.removeAbort();
if (response.ok) entry.resolve(response.value);
else {
const error = new Error(response.error.message);
error.name = response.error.name;
entry.reject(error);
}
},
);
next.addEventListener("error", () => {
rejectPending(new Error("Solver worker failed."));
next.terminate();
if (!terminated) worker = spawnWorker();
});
return next;
};
let worker = spawnWorker();
const restart = (reason: Error): void => {
worker.terminate();
rejectPending(reason);
if (!terminated) worker = spawnWorker();
};
const request: SolverWorkerClient["request"] = (operation, options = {}) => {
if (terminated)
return Promise.reject(new Error("Solver worker was terminated."));
const timeoutMs = options.timeoutMs ?? 125_000;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) {
return Promise.reject(
new RangeError("Worker timeout must be from 1 to 300000 milliseconds."),
);
}
if (options.signal?.aborted === true) {
return Promise.reject(
new DOMException("The operation was aborted.", "AbortError"),
);
}
if (options.supersede !== false && pending.size > 0) {
restart(new DOMException("The operation was superseded.", "AbortError"));
}
sequence += 1;
const id = `${Date.now().toString(36)}-${sequence.toString(36)}`;
const message: SolverWorkerRequest = { id, operation };
return new Promise<SolverWorkerValue>((resolve, reject) => {
const abort = (): void => {
if (!pending.has(id)) return;
restart(new DOMException("The operation was aborted.", "AbortError"));
};
const timer = setTimeout(() => {
if (!pending.has(id)) return;
restart(new Error("Solver worker response timed out."));
}, timeoutMs);
options.signal?.addEventListener("abort", abort, { once: true });
pending.set(id, {
resolve,
reject,
timer,
removeAbort: () => options.signal?.removeEventListener("abort", abort),
});
worker.postMessage(message);
});
};
return {
request,
terminate: () => {
terminated = true;
worker.terminate();
rejectPending(new Error("Solver worker was terminated."));
},
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./client";
export * from "./protocol";
+63
View File
@@ -0,0 +1,63 @@
import type { PuzzleDefinition } from "../domain";
import type {
ExactSolveOptions,
ExactSolveResult,
GenerateClassicOptions,
KillerCombinationOptions,
KillerCombinationResult,
LogicalSolveOptions,
LogicalSolveResult,
MinimizeOptions,
} from "../solver";
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
export type SolverWorkerOperation =
| {
readonly kind: "solve";
readonly puzzle: PuzzleDefinition;
readonly options?: ExactSolveOptions;
}
| {
readonly kind: "logical";
readonly puzzle: PuzzleDefinition;
readonly options?: LogicalSolveOptions;
}
| { readonly kind: "generate"; readonly options?: GenerateClassicOptions }
| {
readonly kind: "minimize";
readonly puzzle: PuzzleDefinition;
readonly options?: MinimizeOptions;
}
| {
readonly kind: "killer-combinations";
readonly options: KillerCombinationOptions;
};
export interface SolverWorkerRequest {
readonly id: string;
readonly operation: SolverWorkerOperation;
}
export type SolverWorkerValue =
| ExactSolveResult
| LogicalSolveResult
| NormalizedPuzzle
| KillerCombinationResult;
export interface SolverWorkerError {
readonly name: string;
readonly message: string;
readonly issues?: readonly ValidationIssue[];
}
export type SolverWorkerResponse =
| {
readonly id: string;
readonly ok: true;
readonly value: SolverWorkerValue;
}
| {
readonly id: string;
readonly ok: false;
readonly error: SolverWorkerError;
};
+66
View File
@@ -0,0 +1,66 @@
/// <reference lib="webworker" />
import { PuzzleValidationError } from "../domain";
import {
generateClassic,
killerDigitCombinations,
minimizePuzzle,
solveExact,
solveLogically,
} from "../solver";
import type {
SolverWorkerRequest,
SolverWorkerResponse,
SolverWorkerValue,
} from "./protocol";
const scope = self as unknown as DedicatedWorkerGlobalScope;
function run(request: SolverWorkerRequest): SolverWorkerValue {
const operation = request.operation;
switch (operation.kind) {
case "solve":
return solveExact(operation.puzzle, operation.options);
case "logical":
return solveLogically(operation.puzzle, operation.options);
case "generate":
return generateClassic(operation.options);
case "minimize":
return minimizePuzzle(operation.puzzle, operation.options);
case "killer-combinations":
return killerDigitCombinations(operation.options);
}
}
scope.addEventListener("message", (event: MessageEvent<unknown>) => {
const input = event.data;
if (
typeof input !== "object" ||
input === null ||
typeof (input as { id?: unknown }).id !== "string" ||
typeof (input as { operation?: unknown }).operation !== "object"
) {
return;
}
const request = input as SolverWorkerRequest;
let response: SolverWorkerResponse;
try {
response = { id: request.id, ok: true, value: run(request) };
} catch (error) {
response = {
id: request.id,
ok: false,
error: {
name: error instanceof Error ? error.name : "Error",
message:
error instanceof Error ? error.message : "Unknown solver error",
...(error instanceof PuzzleValidationError
? { issues: error.issues }
: {}),
},
};
}
scope.postMessage(response);
});
export {};
+56
View File
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
test("loads standalone and keeps the core play workflow local", async ({
page,
}) => {
const runtimeErrors: string[] = [];
page.on("pageerror", (error) => runtimeErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
await page.goto("/deep/nested/sudoku/");
await expect(
page.getByRole("heading", { name: "A first classic" }),
).toBeVisible();
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
await expect(grid).toBeVisible();
await expect(grid.getByRole("gridcell")).toHaveCount(81);
await grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }).click();
await page
.getByRole("group", { name: "Digits" })
.getByRole("button", { name: "2", exact: true })
.click();
await expect(
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeVisible();
await page.getByRole("button", { name: "Undo" }).click();
await expect(
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
).toBeVisible();
await page.getByRole("button", { name: "Helpers" }).click();
await expect(
page.getByRole("heading", { name: "Sudoku helpers" }),
).toBeVisible();
await expect(
page.getByRole("tab", { name: "Killer combinations" }),
).toHaveAttribute("aria-selected", "true");
await expect(page.locator(".helper-result")).toContainText("4 combinations");
await page.getByRole("button", { name: "Import / export" }).click();
await expect(
page.getByRole("heading", { name: "Import and export" }),
).toBeVisible();
await expect(
page.getByText(/Server-only short IDs are never fetched\./u),
).toBeVisible();
await expect(
page.getByText(/Share links are self-contained\./u),
).toBeVisible();
expect(runtimeErrors).toEqual([]);
});
+130
View File
@@ -0,0 +1,130 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Workbench } from "../../src/components/Workbench";
class WorkerStub {
addEventListener() {}
postMessage() {}
terminate() {}
}
beforeAll(() => {
vi.stubGlobal("Worker", WorkerStub);
});
describe("Sudoku workbench", () => {
it("opens with the local classic example rendered as an accessible grid", () => {
render(<Workbench />);
expect(
screen.getByRole("heading", { name: "A first classic" }),
).toBeInTheDocument();
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
expect(within(grid).getAllByRole("gridcell")).toHaveLength(81);
expect(
within(grid).getByRole("gridcell", {
name: "Row 1, column 1, 4",
}),
).toHaveClass("is-given");
expect(
within(grid).getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toBeInTheDocument();
});
it("enters a digit in an empty cell and restores it with undo", async () => {
const user = userEvent.setup();
render(<Workbench />);
const emptyCell = screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
});
fireEvent.pointerDown(emptyCell, { buttons: 1 });
await user.click(
within(screen.getByRole("group", { name: "Digits" })).getByRole(
"button",
{ name: "2" },
),
);
expect(
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
).toBeInTheDocument();
const undo = screen.getByRole("button", { name: "Undo" });
expect(undo).toBeEnabled();
await user.click(undo);
expect(
screen.getByRole("gridcell", {
name: "Row 1, column 3, empty",
}),
).toBeInTheDocument();
expect(undo).toBeDisabled();
});
it("switches between setting, solving, playing and helper workspaces", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "Set" }));
expect(
screen.getByRole("heading", { name: "Set a puzzle" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Enter givens" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Solve" }));
expect(
screen.getByRole("heading", { name: "Solve and verify" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Helpers" }));
expect(
screen.getByRole("heading", { name: "Sudoku helpers" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Killer combinations" }),
).toHaveAttribute("aria-selected", "true");
expect(document.querySelector(".helper-result")).toHaveTextContent(
"combinations",
);
await user.click(screen.getByRole("tab", { name: "45-rule residual" }));
expect(
screen.getByLabelText("Accounted values or cage sums"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Play" }));
expect(
screen.getByRole("heading", { name: "Enter your solve" }),
).toBeInTheDocument();
});
it("makes the import boundary and self-contained local sharing explicit", async () => {
const user = userEvent.setup();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "Import / export" }));
expect(
screen.getByRole("heading", { name: "Import and export" }),
).toBeInTheDocument();
expect(
screen.getByText(/Server-only short IDs are never fetched\./u),
).toBeInTheDocument();
expect(
screen.getByText(/Share links are self-contained\./u),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Copy local share URL" }),
);
expect(
await screen.findByText("Local share URL copied."),
).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { SAMPLE_PUZZLES } from "../../src/data/samples";
import { solveExact, solveLogically } from "../../src/solver";
describe("bundled original samples", () => {
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
const result = solveExact(puzzle, { maxSolutions: 2 });
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
});
it("solves the generated classic with the supported logical techniques", () => {
expect(solveLogically(SAMPLE_PUZZLES[0]).status).toBe("solved");
});
});
+177
View File
@@ -0,0 +1,177 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
allCandidates,
candidatesForCell,
classicRegions,
createEmptyPuzzle,
findConflicts,
normalizePuzzle,
validatePuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../../src/domain";
const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
function puzzle4(overrides: Partial<PuzzleDefinition> = {}): PuzzleDefinition {
return {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
regions: classicRegions(4),
constraints: [],
...overrides,
};
}
describe("puzzle domain", () => {
it("creates valid rectangular regions for every supported size", () => {
fc.assert(
fc.property(fc.integer({ min: 4, max: 16 }), (size) => {
const regions = classicRegions(size);
expect(regions).toHaveLength(size * size);
for (let region = 0; region < size; region += 1) {
expect(regions.filter((entry) => entry === region)).toHaveLength(
size,
);
}
expect(validatePuzzle(createEmptyPuzzle(size)).valid).toBe(true);
}),
);
});
it("strictly rejects unknown, oversized, malformed, and contradictory data", () => {
expect(validatePuzzle({ ...puzzle4(), surprise: true }).valid).toBe(false);
expect(validatePuzzle({ ...puzzle4(), title: "x".repeat(257) }).valid).toBe(
false,
);
expect(
validatePuzzle({
...puzzle4(),
givens: [1, 1, ...new Array<number>(14).fill(0)],
}).valid,
).toBe(false);
expect(
validatePuzzle({ ...puzzle4(), regions: new Array<number>(16).fill(0) })
.valid,
).toBe(false);
expect(
validatePuzzle({
...puzzle4(),
constraints: [{ type: "thermo", cells: [0, 0], extra: true }],
}).valid,
).toBe(false);
});
it("accepts all supported constraint shapes and preserves bounded metadata", () => {
const constraints: VariantConstraint[] = [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
{ type: "anti-knight" },
{ type: "anti-king" },
{ type: "non-consecutive" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [0, 1, 2] },
{ type: "arrow", bulb: [0], line: [1, 2] },
{ type: "kropki", a: 0, b: 1, kind: "white" },
{ type: "kropki", a: 0, b: 4, kind: "black" },
{ type: "xv", a: 0, b: 1, total: 5 },
{ type: "inequality", lesser: 0, greater: 1 },
{ type: "renban", cells: [0, 1, 2] },
{ type: "palindrome", cells: [0, 5] },
];
const definition = puzzle4({
id: "demo",
title: "Variant",
rules: "Local rules",
constraints,
});
const result = validatePuzzle(definition);
expect(result).toEqual({ valid: true, issues: [] });
expect(normalizePuzzle(definition)).toMatchObject({
id: "demo",
rules: "Local rules",
});
});
it("validates an optional solution against givens and constraints", () => {
const constraints: VariantConstraint[] = [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [0, 1, 2, 3] },
{ type: "arrow", bulb: [3], line: [0, 2] },
{ type: "kropki", a: 0, b: 1, kind: "white" },
{ type: "kropki", a: 0, b: 1, kind: "black" },
{ type: "xv", a: 0, b: 3, total: 5 },
{ type: "inequality", lesser: 0, greater: 1 },
{ type: "renban", cells: [0, 1, 2, 3] },
{ type: "palindrome", cells: [0, 6] },
];
expect(
validatePuzzle(
puzzle4({
givens: [1, ...new Array<number>(15).fill(0)],
solution: solved4,
constraints,
}),
).valid,
).toBe(true);
const wrong = [...solved4];
wrong[0] = 2;
expect(
validatePuzzle(
puzzle4({
givens: [1, ...new Array<number>(15).fill(0)],
solution: wrong,
}),
).valid,
).toBe(false);
});
it("computes classic candidates and reports exact conflict cells", () => {
const values = [1, 2, 3, 0, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3];
const puzzle = puzzle4({ givens: new Array<number>(16).fill(0) });
expect(candidatesForCell(puzzle, values, 3)).toEqual([4]);
expect(allCandidates(puzzle, values)[3]).toEqual([4]);
const conflict = [...values];
conflict[3] = 1;
expect(
findConflicts(puzzle, conflict).some(
({ cells }) => cells.includes(0) && cells.includes(3),
),
).toBe(true);
});
it("enforces anti, adjacency, line, sum, ratio and equality constraints in candidates", () => {
const scenarios: Array<
readonly [VariantConstraint, number, number, boolean]
> = [
[{ type: "anti-knight" }, 6, 1, false],
[{ type: "anti-king" }, 5, 1, false],
[{ type: "non-consecutive" }, 1, 2, false],
[{ type: "killer-cage", cells: [0, 1], sum: 3 }, 1, 4, false],
[{ type: "thermo", cells: [0, 1, 2, 3] }, 1, 1, false],
[{ type: "arrow", bulb: [0], line: [1, 2] }, 2, 4, false],
[{ type: "kropki", a: 0, b: 1, kind: "black" }, 1, 3, false],
[{ type: "xv", a: 0, b: 1, total: 5 }, 1, 3, false],
[{ type: "inequality", lesser: 0, greater: 1 }, 1, 1, false],
[{ type: "renban", cells: [0, 1, 2] }, 2, 4, false],
[{ type: "palindrome", cells: [0, 5] }, 5, 2, false],
];
for (const [constraint, cell, candidate, expected] of scenarios) {
const values = new Array<number>(16).fill(0);
values[0] = 1;
if (constraint.type === "arrow") values[1] = 1;
if (constraint.type === "renban") values[1] = 2;
expect(
candidatesForCell(
puzzle4({ constraints: [constraint] }),
values,
cell,
).includes(candidate),
).toBe(expected);
}
});
});
+104
View File
@@ -0,0 +1,104 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
MAX_DOCUMENT_BYTES,
SUDOKU_DOCUMENT_SCHEMA,
SudokuFormatError,
decodePuzzleHash,
encodePuzzleHash,
normalizeSudokuDocument,
parsePlainGrid,
parseSudokuDocument,
serializePlainGrid,
serializeSudokuDocument,
type SudokuDocument,
} from "../../src/formats";
function document(givens: readonly number[], size = 9): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size,
givens,
constraints: [],
};
}
describe("Sudoku Tools document format", () => {
it("normalizes and round-trips a versioned document", () => {
const source: SudokuDocument = {
...document(Array<number>(81).fill(0)),
title: "Local puzzle",
solution: Array.from({ length: 81 }, (_, index) => (index % 9) + 1),
candidates: Array.from({ length: 81 }, () => [3, 1, 3]),
cornerMarks: Array.from({ length: 81 }, () => [8, 2, 8]),
centerMarks: Array.from({ length: 81 }, () => [7, 4]),
colors: Array.from({ length: 81 }, (_, index) => index % 9),
elapsedMs: 12_345,
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
};
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed).toEqual({
...source,
candidates: Array.from({ length: 81 }, () => [1, 3]),
cornerMarks: Array.from({ length: 81 }, () => [2, 8]),
centerMarks: Array.from({ length: 81 }, () => [4, 7]),
});
});
it("rejects unsupported versions and oversized input", () => {
expect(() =>
normalizeSudokuDocument({ ...document(Array(81).fill(0)), version: 2 }),
).toThrow(/Unsupported puzzle document version/u);
expect(() =>
parseSudokuDocument(" ".repeat(MAX_DOCUMENT_BYTES + 1)),
).toThrowError(SudokuFormatError);
expect(() =>
normalizeSudokuDocument({
...document([1, ...Array<number>(15).fill(0)], 4),
values: [2, ...Array<number>(15).fill(0)],
}),
).toThrow(/preserve its given digit/u);
});
it("round-trips compact share hashes", () => {
const source = {
...document(Array<number>(81).fill(0)),
title: "Hash # & Unicode 🧩",
constraints: [{ type: "diagonal", direction: "main" } as const],
};
const hash = encodePuzzleHash(source);
expect(hash).toMatch(/^#sudoku=v1\./u);
expect(decodePuzzleHash(`https://example.invalid/tools/${hash}`)).toEqual(
source,
);
});
it("round-trips grids for every supported size and symbol", () => {
fc.assert(
fc.property(
fc.integer({ min: 4, max: 16 }).chain((size) =>
fc
.array(fc.integer({ min: 0, max: size }), {
minLength: size * size,
maxLength: size * size,
})
.map((givens) => ({ size, givens })),
),
({ size, givens }) => {
const text = serializePlainGrid(document(givens, size));
expect(parsePlainGrid(text)).toMatchObject({ size, givens });
},
),
{ numRuns: 80 },
);
});
it("accepts human grid separators and diagnoses bad lengths", () => {
const row = "1 . . 4";
const parsed = parsePlainGrid(`${row}\n${row}\n${row}\n${row}`);
expect(parsed.size).toBe(4);
expect(parsed.givens.slice(0, 4)).toEqual([1, 0, 0, 4]);
expect(() => parsePlainGrid("1234")).toThrow(/side length/u);
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import {
NetworkPuzzleIdError,
SUDOKU_DOCUMENT_SCHEMA,
addressFromCellIndex,
cellIndexFromAddress,
exportFpuzzles,
exportFpuzzlesUrl,
importFpuzzles,
parseFpuzzles,
toDomainPuzzle,
type SudokuDocument,
} from "../../src/formats";
const emptyGrid = () =>
Array.from({ length: 9 }, () => Array.from({ length: 9 }, () => ({})));
describe("fpuzzles interoperability", () => {
it("imports common givens, regions and variant constraints", () => {
const grid = emptyGrid();
for (let row = 0; row < 9; row += 1) {
for (let column = 0; column < 9; column += 1) {
grid[row]![column] = {
region: Math.floor(row / 3) * 3 + Math.floor(column / 3),
};
}
}
grid[0]![0] = { value: 5, given: true, region: 0 };
grid[0]![1] = {
value: 3,
region: 0,
centerPencilMarks: [7, 2],
cornerPencilMarks: [9, 1],
};
const puzzle = parseFpuzzles({
size: 9,
title: "Variants",
author: "Setter",
ruleset: "Normal Sudoku rules apply.",
grid,
"diagonal+": true,
antiknight: true,
antikingsmove: true,
nonconsecutive: true,
killercage: [{ cells: ["R1C1", "R1C2"], value: "8" }],
thermometer: [{ lines: [["R2C1", "R2C2", "R2C3"]] }],
arrow: [{ cells: ["R3C1"], lines: [["R3C1", "R3C2"]] }],
difference: [{ cells: ["R4C1", "R4C2"], value: "1" }],
ratio: [{ cells: ["R4C2", "R4C3"], value: "2" }],
xv: [{ cells: ["R5C1", "R5C2"], value: "V" }],
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
renban: [{ lines: [["R7C1", "R7C2"]] }],
palindrome: [{ lines: [["R8C1", "R8C2"]] }],
});
expect(puzzle.givens[0]).toBe(5);
expect(puzzle.values?.[1]).toBe(3);
expect(puzzle.regions?.slice(0, 2)).toEqual([0, 0]);
expect(puzzle.centerMarks?.[1]).toEqual([2, 7]);
expect(puzzle.cornerMarks?.[1]).toEqual([1, 9]);
expect(puzzle.constraints).toEqual(
expect.arrayContaining([
{ type: "killer-cage", cells: [0, 1], sum: 8, noRepeat: true },
{ type: "kropki", a: 27, b: 28, kind: "white" },
{ type: "kropki", a: 28, b: 29, kind: "black" },
{ type: "inequality", lesser: 46, greater: 45 },
]),
);
expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
});
it("exports and imports a self-contained SudokuPad fpuzzles URL", () => {
const source: SudokuDocument = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1,
size: 9,
givens: [5, ...Array<number>(80).fill(0)],
constraints: [
{ type: "diagonal", direction: "anti" },
{ type: "killer-cage", cells: [0, 1], sum: 8 },
{ type: "thermo", cells: [9, 10, 11] },
{ type: "arrow", bulb: [18], line: [19] },
{ type: "kropki", a: 27, b: 28, kind: "white" },
{ type: "xv", a: 36, b: 37, total: 10 },
{ type: "inequality", lesser: 45, greater: 46 },
{ type: "renban", cells: [54, 55] },
{ type: "palindrome", cells: [63, 64] },
],
title: "Round trip",
};
const exported = exportFpuzzles(source);
expect(exported.grid).toBeInstanceOf(Array);
const imported = importFpuzzles(exportFpuzzlesUrl(source));
expect(imported.givens).toEqual(source.givens);
const expected = source.constraints.map((constraint) =>
constraint.type === "killer-cage"
? { ...constraint, noRepeat: true }
: constraint,
);
expect(imported.constraints).toHaveLength(expected.length);
expect(imported.constraints).toEqual(expect.arrayContaining(expected));
});
it("recognizes server-only short puzzle IDs without making a request", () => {
expect(() => importFpuzzles("https://sudokupad.app/abc123")).toThrowError(
NetworkPuzzleIdError,
);
expect(() =>
importFpuzzles("https://sudokupad.app/?puzzleid=abc123"),
).toThrow(/local-only app cannot fetch/u);
});
it("rejects unsupported generalized dots instead of weakening them", () => {
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
difference: [{ cells: ["R1C1", "R1C2"], value: "2" }],
}),
).toThrow(/Difference-2 dots are not supported/u);
});
it("rejects unsupported or unknown rules instead of silently weakening them", () => {
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
odd: [{ cell: "R1C1" }],
}),
).toThrow(/odd cells.*silently weakening/u);
expect(() =>
parseFpuzzles({
size: 9,
grid: emptyGrid(),
futureConstraint: [{ cells: ["R1C1"] }],
}),
).toThrow(/Unknown fpuzzles field/u);
});
it("round-trips canonical fpuzzles cell addresses", () => {
for (let cell = 0; cell < 256; cell += 1) {
expect(cellIndexFromAddress(addressFromCellIndex(cell, 16), 16)).toBe(
cell,
);
}
});
});
+96
View File
@@ -0,0 +1,96 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
analyzeKillerCage,
calculateKillerCombinations,
} from "../../src/helpers";
describe("killer cage combinations", () => {
it("enumerates the familiar two-cell sum 10 combinations", () => {
expect(calculateKillerCombinations({ cellCount: 2, sum: 10 })).toEqual([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
]);
});
it("supports allowed, required, excluded and repeated digits", () => {
expect(
calculateKillerCombinations({
cellCount: 3,
sum: 10,
allowedDigits: [1, 2, 3, 4, 5],
excludedDigits: [1],
requiredDigits: [5],
}),
).toEqual([[2, 3, 5]]);
expect(
calculateKillerCombinations({
cellCount: 2,
sum: 10,
allowRepeats: true,
}),
).toContainEqual([5, 5]);
});
it("filters assignments through per-cell candidates", () => {
const result = analyzeKillerCage({
cellCount: 2,
sum: 10,
candidates: [
[1, 2],
[8, 9],
],
});
expect(result.combinations).toEqual([
[1, 9],
[2, 8],
]);
expect(result.assignments).toEqual([
[1, 9],
[2, 8],
]);
expect(result.possibleByCell).toEqual([
[1, 2],
[8, 9],
]);
expect(result.necessaryDigits).toEqual([]);
});
it("reports digits necessary in every surviving combination", () => {
const result = analyzeKillerCage({ cellCount: 3, sum: 6 });
expect(result.combinations).toEqual([[1, 2, 3]]);
expect(result.necessaryDigits).toEqual([1, 2, 3]);
});
it("satisfies combination invariants for generated inputs", () => {
fc.assert(
fc.property(
fc.integer({ min: 4, max: 12 }),
fc.integer({ min: 1, max: 5 }),
fc.integer({ min: 1, max: 60 }),
(size, count, sum) => {
if (count > size || sum > size * count) return;
const result = calculateKillerCombinations({
size,
cellCount: count,
sum,
});
const keys = new Set<string>();
for (const combination of result) {
expect(combination).toHaveLength(count);
expect(combination.reduce((total, digit) => total + digit, 0)).toBe(
sum,
);
expect(combination).toEqual([...combination].sort((a, b) => a - b));
expect(new Set(combination).size).toBe(count);
keys.add(combination.join(","));
}
expect(keys.size).toBe(result.length);
},
),
{ numRuns: 100 },
);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
calculateResidual,
fortyFiveRuleResidual,
inequalityPairs,
kropkiPairs,
relationPairs,
xvPairs,
} from "../../src/helpers";
describe("relation pair tables", () => {
it("builds white and black Kropki tables", () => {
expect(kropkiPairs("white", 4)).toEqual([
[1, 2],
[2, 1],
[2, 3],
[3, 2],
[3, 4],
[4, 3],
]);
expect(kropkiPairs("black", 4)).toEqual([
[1, 2],
[2, 1],
[2, 4],
[4, 2],
]);
});
it("builds XV and directed inequality tables with candidate filtering", () => {
expect(xvPairs(5, 4)).toEqual([
[1, 4],
[2, 3],
[3, 2],
[4, 1],
]);
expect(inequalityPairs("<", 4, [2, 4], [1, 3])).toEqual([[2, 3]]);
expect(
relationPairs({ type: "difference", difference: 2 }, 4),
).toContainEqual([1, 3]);
});
});
describe("45-rule helpers", () => {
it("calculates a residual and its combinations", () => {
const result = calculateResidual({ knownSums: [10, 20], unknownCount: 2 });
expect(result.residual).toBe(15);
expect(result.analysis?.combinations).toEqual([
[6, 9],
[7, 8],
]);
});
it("subtracts complete cages and values but reports crossing cages", () => {
const result = fortyFiveRuleResidual({
unitCells: [0, 1, 2, 3, 4, 5, 6, 7, 8],
cages: [
{ cells: [0, 1], sum: 3 },
{ cells: [8, 17], sum: 10 },
],
knownValues: { 2: 4 },
});
expect(result.accounted).toBe(7);
expect(result.residual).toBe(38);
expect(result.residualCells).toEqual([3, 4, 5, 6, 7, 8]);
expect(result.crossingCages).toEqual([{ cells: [8, 17], sum: 10 }]);
});
});
+126
View File
@@ -0,0 +1,126 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
classicRegions,
isSolved,
type PuzzleDefinition,
} from "../../src/domain";
import {
countSolutions,
generateClassic,
minimizePuzzle,
solveExact,
} from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const puzzle4: PuzzleDefinition = {
version: 1,
size: 4,
givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
regions: classicRegions(4),
constraints: [],
};
describe("exact solver", () => {
it("solves a classic puzzle with MRV without mutating its input", () => {
const original = [...puzzle4.givens];
const result = solveExact(puzzle4);
expect(result.count).toBe(1);
expect(result.solutions[0]).toEqual(solution4);
expect(result.truncated).toBe(false);
expect(puzzle4.givens).toEqual(original);
expect(isSolved(puzzle4, result.solutions[0] ?? [])).toBe(true);
});
it("caps solution counting and reports that unexplored work remains", () => {
const empty: PuzzleDefinition = {
...puzzle4,
givens: new Array<number>(16).fill(0),
};
const result = solveExact(empty, { maxSolutions: 2 });
expect(result.count).toBe(2);
expect(result.truncated).toBe(true);
expect(result.limitReason).toBe("solution-cap");
expect(countSolutions(empty)).toBe(2);
});
it("solves a puzzle combining supported local constraints", () => {
const variant: PuzzleDefinition = {
...puzzle4,
givens: solution4.map((value, cell) => (cell % 3 === 0 ? 0 : value)),
constraints: [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "thermo", cells: [0, 1, 2, 3] },
{ type: "arrow", bulb: [3], line: [0, 2] },
{ type: "kropki", a: 0, b: 1, kind: "black" },
{ type: "xv", a: 0, b: 3, total: 5 },
{ type: "inequality", lesser: 0, greater: 1 },
{ type: "renban", cells: [0, 1, 2, 3] },
{ type: "palindrome", cells: [0, 6] },
],
};
expect(solveExact(variant).solutions[0]).toEqual(solution4);
});
it("generates deterministic, unique 4x4 puzzles for arbitrary seeds", () => {
fc.assert(
fc.property(fc.integer(), (seed) => {
const generated = generateClassic({
size: 4,
seed,
targetClues: 8,
symmetry: "rotational",
});
const again = generateClassic({
size: 4,
seed,
targetClues: 8,
symmetry: "rotational",
});
expect(generated.givens).toEqual(again.givens);
const checked = solveExact(generated, { maxSolutions: 2 });
expect(checked.count).toBe(1);
expect(checked.truncated).toBe(false);
expect(checked.solutions[0]).toEqual(generated.solution);
}),
{ numRuns: 12 },
);
});
it("minimizes only when uniqueness is retained", () => {
const full: PuzzleDefinition = {
version: 1,
size: 4,
givens: solution4,
solution: solution4,
regions: classicRegions(4),
constraints: [],
};
const minimized = minimizePuzzle(full, {
seed: "minimal",
targetClues: 6,
symmetry: "none",
});
expect(minimized.givens.filter(Boolean).length).toBeLessThan(16);
expect(solveExact(minimized).count).toBe(1);
});
it("generates a practical uniquely solvable 9x9 puzzle", () => {
const generated = generateClassic({
size: 9,
seed: "nine-by-nine",
targetClues: 35,
symmetry: "rotational",
maxChecks: 60,
solveTimeoutMs: 2_000,
});
expect(generated.givens.filter(Boolean).length).toBeGreaterThanOrEqual(35);
const result = solveExact(generated, { timeoutMs: 5_000 });
expect(result.count).toBe(1);
expect(result.truncated).toBe(false);
expect(result.solutions[0]).toEqual(generated.solution);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import { killerDigitCombinations, solveLogically } from "../../src/solver";
const easy: PuzzleDefinition = {
version: 1,
size: 9,
givens: [
5, 3, 0, 0, 7, 0, 0, 0, 0, 6, 0, 0, 1, 9, 5, 0, 0, 0, 0, 9, 8, 0, 0, 0, 0,
6, 0, 8, 0, 0, 0, 6, 0, 0, 0, 3, 4, 0, 0, 8, 0, 3, 0, 0, 1, 7, 0, 0, 0, 2,
0, 0, 0, 6, 0, 6, 0, 0, 0, 0, 2, 8, 0, 0, 0, 0, 4, 1, 9, 0, 0, 5, 0, 0, 0,
0, 8, 0, 0, 7, 9,
],
regions: classicRegions(9),
constraints: [],
};
describe("logical solver", () => {
it("solves a standard puzzle and emits inspectable steps", () => {
const result = solveLogically(easy);
expect(result.status).toBe("solved");
expect(result.values.every((value) => value > 0)).toBe(true);
expect(result.steps.length).toBeGreaterThan(0);
expect(result.steps.every((step) => step.explanation.length > 0)).toBe(
true,
);
expect(
result.steps.some(
({ technique }) =>
technique === "naked-single" || technique === "hidden-single",
),
).toBe(true);
});
it("stops safely at the configured step bound", () => {
const result = solveLogically(easy, { maxSteps: 1 });
expect(result.status).toBe("step-limit");
expect(result.steps).toHaveLength(1);
});
it("returns bounded killer combinations", () => {
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
).toEqual([
[1, 9],
[2, 8],
[3, 7],
[4, 6],
]);
expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10, noRepeat: false })
.combinations,
).toContainEqual([5, 5]);
expect(
killerDigitCombinations({ size: 16, count: 8, sum: 68, maxResults: 1 })
.truncated,
).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
createSession,
enterSelection,
eraseSelection,
maskValues,
valueForKey,
} from "../../src/state/session";
describe("play session", () => {
it("protects givens and toggles values and notes", () => {
const givens = [5, 0, 0, 0];
let state = createSession(givens);
state = enterSelection(state, new Set([0, 1]), "value", 3, givens);
expect(state.values).toEqual([5, 3, 0, 0]);
state = enterSelection(state, new Set([2]), "center", 2, givens);
state = enterSelection(state, new Set([2]), "center", 4, givens);
expect(maskValues(state.centerMarks[2]!, 4)).toEqual([2, 4]);
state = eraseSelection(state, new Set([0, 1]), "value", givens);
expect(state.values).toEqual([5, 0, 0, 0]);
});
it("maps keyboard symbols through hexadecimal-sized grids", () => {
expect(valueForKey("9", 9)).toBe(9);
expect(valueForKey("A", 16)).toBe(10);
expect(valueForKey("G", 16)).toBe(16);
expect(valueForKey("H", 16)).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
import {
PROJECT_RECORD_SCHEMA,
ProjectLibrary,
createProjectRecord,
normalizeProjectRecord,
} from "../../src/storage";
const puzzle = {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: 1 as const,
size: 9,
givens: Array<number>(81).fill(0),
constraints: [],
};
describe("local project library", () => {
it("stores independent bounded clones in the memory fallback", async () => {
const library = new ProjectLibrary({ indexedDB: null });
const record = createProjectRecord(puzzle, {
id: "one",
title: "First",
now: 100,
progress: {
version: 1,
values: Array<number>(81).fill(0),
cornerMarks: Array.from({ length: 81 }, () => [1, 2]),
centerMarks: Array.from({ length: 81 }, () => [3]),
colors: Array<number>(81).fill(2),
elapsedMs: 1234,
},
});
await library.put(record);
const loaded = await library.get("one");
expect(loaded).toEqual(record);
(loaded?.puzzle.givens as number[])[0] = 9;
expect((await library.get("one"))?.puzzle.givens[0]).toBe(0);
expect(library.mode).toBe("memory");
});
it("lists newest first, deletes and clears", async () => {
const library = new ProjectLibrary({ indexedDB: null });
await library.put(
createProjectRecord(puzzle, { id: "old", title: "Old", now: 1 }),
);
await library.put(
createProjectRecord(puzzle, { id: "new", title: "New", now: 2 }),
);
expect((await library.list()).map(({ id }) => id)).toEqual(["new", "old"]);
expect(await library.delete("old")).toBe(true);
expect(await library.delete("missing")).toBe(false);
await library.clear();
expect(await library.list()).toEqual([]);
});
it("exports and imports an explicitly versioned library", async () => {
const source = new ProjectLibrary({ indexedDB: null });
await source.put(createProjectRecord(puzzle, { id: "one", now: 10 }));
const exported = await source.exportAll();
const target = new ProjectLibrary({ indexedDB: null });
expect(await target.importAll(exported)).toBe(1);
expect(await target.get("one")).toEqual(await source.get("one"));
});
it("falls back when IndexedDB cannot be opened", async () => {
const broken = {
open: () => {
throw new Error("blocked");
},
} as unknown as IDBFactory;
const library = new ProjectLibrary({ indexedDB: broken });
expect(await library.ready()).toBe("memory");
await library.put(createProjectRecord(puzzle, { id: "fallback", now: 1 }));
expect(await library.get("fallback")).toBeDefined();
});
it("rejects malformed record versions and progress", () => {
expect(() =>
normalizeProjectRecord({
schema: PROJECT_RECORD_SCHEMA,
version: 2,
}),
).toThrow(/Unsupported project record/u);
const record = createProjectRecord(puzzle, { id: "bad", now: 1 });
expect(() =>
normalizeProjectRecord({
...record,
progress: { version: 1, values: [0] },
}),
).toThrow(/must contain 81/u);
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"useDefineForClassFields": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src", "tests"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": [
"vite.config.ts",
"playwright.config.ts",
"eslint.config.mjs",
"scripts/**/*.mjs"
]
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
plugins: [react()],
worker: { format: "es" },
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
css: true,
restoreMocks: true,
exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
},
});