feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/*.zip
|
||||
release/*.zip.sha256
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -0,0 +1,2 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||
always-auth=false
|
||||
@@ -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.
|
||||
@@ -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>.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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>
|
||||
Generated
+4012
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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"] } },
|
||||
],
|
||||
});
|
||||
@@ -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
@@ -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>.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 < 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 & uniqueness
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={busy || puzzle.size > 9}
|
||||
>
|
||||
Generate classic
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>1–9 / A–G</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>
|
||||
);
|
||||
}
|
||||
@@ -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 < second</option>
|
||||
<option value="greater">First > 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 1–9 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 1–4 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;
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./compile";
|
||||
export * from "./geometry";
|
||||
export * from "./rules";
|
||||
export * from "./types";
|
||||
export * from "./validation";
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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] }),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./document";
|
||||
export * from "./fpuzzles";
|
||||
export * from "./grid";
|
||||
export * from "./share";
|
||||
export * from "./types";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./killer";
|
||||
export * from "./relations";
|
||||
export * from "./residual";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./exact";
|
||||
export * from "./generator";
|
||||
export * from "./killer";
|
||||
export * from "./logical";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./library";
|
||||
export * from "./record";
|
||||
export * from "./types";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APPLICATION_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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."));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./protocol";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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/**"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user