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