Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fae9f8dce4 | |||
| 5db04109a2 | |||
| ead4839df4 | |||
| bedb5a0f80 | |||
| 51615debd0 | |||
| be05ee5617 | |||
| 059177c635 | |||
| 3931805b6f | |||
| 3f9891a97c | |||
| 763769f997 | |||
| 4278f2d19e | |||
| 4e876b3197 | |||
| 7473cc69b3 | |||
| 88f1da9f70 | |||
| c1751751af | |||
| bd24159f37 |
+26
-2
@@ -1,7 +1,31 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
node_modules/
|
node_modules/
|
||||||
|
dist
|
||||||
dist/
|
dist/
|
||||||
.superpowers/
|
dist-ssr
|
||||||
.DS_Store
|
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Project-specific
|
||||||
|
.superpowers/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
GITEA_API.md
|
GITEA_API.md
|
||||||
|
|||||||
@@ -0,0 +1,865 @@
|
|||||||
|
{
|
||||||
|
"version": "1.5.0",
|
||||||
|
"plugins_used": [
|
||||||
|
{
|
||||||
|
"name": "ArtifactoryDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AWSKeyDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AzureStorageKeyDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Base64HighEntropyString",
|
||||||
|
"limit": 4.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BasicAuthDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CloudantDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DiscordBotTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GitHubTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GitLabTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HexHighEntropyString",
|
||||||
|
"limit": 3.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IbmCloudIamDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IbmCosHmacDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IPPublicDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JwtTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KeywordDetector",
|
||||||
|
"keyword_exclude": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MailchimpDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NpmDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenAIDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PrivateKeyDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PypiTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SendGridDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SlackDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SoftlayerDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SquareOAuthDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StripeDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TelegramBotTokenDetector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TwilioKeyDetector"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filters_used": [
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
||||||
|
"min_level": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"results": {
|
||||||
|
"pnpm-lock.yaml": [
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "88dcf5e48940431d207eb6beeda3f28b52b9dcd6",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 43
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "9b1aeb3238bb5411805279e20afb4fb96fa7490d",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "e8cc5935afdfd3788eae945e3db3f42e2c3acee6",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "8733ba82d33885a92ca3009eb6ba61e505a1ee9a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 52
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "3e3522c83d724aa4133ee6bb6d627e4390be31a5",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "9d1b9dbec9f048e048f4dd38591947ca7deff5dd",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "f60c00cacddf514ad090fbde7005d8721adc0b89",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b0e9cbf320ebe74507bcdaae3f0b40740459a092",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "f274e993f5da6ef04f9ab21f5152b7f16f86fc45",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "63ec2819c4e19e52c1d683e5e3e2e6ce0b6cac93",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 74
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "907c22434e5fe282d088f338a9e6155fb51ce241",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 77
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "e694aeb21a36497b6eb9bc56f884bd4c69ac5add",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 83
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "4d2585f3339105214d7685879b53a916f5aea9fa",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 89
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "f1833f6e7490c05a1a76205a4bdafb80e88e04e6",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "cacf9ecad80e93e92e88556952a24dedeae071ae",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "525571cc69c07ea83a7f575ab13f8174a1279317",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 107
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "be823e3374be22151f74deb53ad9b52ba62e1794",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 114
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "f057529a66a11ba122564fc9c36ccefe29df6f1a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 121
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2e065f3f711d422e5ef5abdcd9c255dea44113f7",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 128
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "04eddfeea8ec2c47eac1fdc1f6033f88d175e4cf",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 135
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "de1fe5a83dde4beebb24a15f2ebf259aa1adb023",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 142
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "aaa92f5d670cc00ba757939cebeda4c34436c037",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 149
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2cd4187cd1381a5ff2776451a79449d69a7790ec",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 155
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "a427b668faefb136b19160d59226685364b3a9e5",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "007e6990046ea7fce8af0547e80941651f78eb17",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b0da61692236cf0f51dfca9bea5b058e6db2a173",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 172
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "1e9ff9fee5ad181d3f63618e0267e8ad42826e83",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 175
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "0e08e31e43853476fc57d10643d8c871082c08b3",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 178
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "de37fe98add6f4734b9251d9b8c3e9d0772f2a7a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 183
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "ae55501077a6c44a2607882f75ee062a47bc2a6a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 190
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2a99232e74af9898db82959893e9c64814b15714",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 193
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "7739c7a36e8b5c9759887d54c3d3a252d017cb71",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 196
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "d4dda0583372eb865e22ea00ae160ceb315aa0df",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 199
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "fa52615bd39e56c7366b834e0c9b496bbb96f043",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 202
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2ff8e8e4076d74fa172588e8f030832fc1d1577f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 205
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "5f411c7e6073b67aca1c9043e3e952322dc21d46",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 208
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "4ea248d159b60f6d7fef97d151e4684cb2bd1b7c",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 211
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "40efaa4408aa7a51f20a5dafc886853a090bdf7f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 214
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "bbd4d55c127ca96a683a41840e2cf89a50b2d879",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 225
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "ca76999d735911c431e5bdd1fea97ba4933bc187",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 228
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "902751d36d478d198a9171b2692aea0bcee1d728",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 231
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "f9e756c256be01a72d3b154b30203f0e3bd4c554",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 234
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b510709833d6f2ff1ea90f5cfa4b514c6c56b2ce",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 237
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b2c03df0ed8f8caded3abab58c3b2e3b645dbb28",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "5bb90e7e3d7847438b155c9fb564dfd279383855",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 245
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "1c504c77e2b9f1ea0e1770e65673fd7f8b3f4c8f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 249
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "4a6acdb47bbd22c85e10baf5921ddbb3c09a787b",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 253
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "bc23715592b7602434c2776230d19f43fc479898",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 257
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "8095985919187b5be33de4b775c7a5e7562aafee",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 261
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "8ff61e34bccd3c9f85c8a24db9723d984fd74314",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 264
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "5974b42acc3c558a08c734a45a10785534b5d382",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 268
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "08c8ee0987f57add8cd4071488caef8ec431336a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 272
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "c0c7aeed5e5a888b2aa3cdb739955fd27d25335e",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 275
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "bef68af5091abf3bd912b28fd2f8b438426dd79d",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 279
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "deb1c562595f81e98efdd28daf90161dc8ab8013",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 283
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "d3bc9e85ef73bc81be3c9767f7c26c7600f357c0",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 286
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "4a1a266072e5f401e1aa928a9bf6d94bfeab4806",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 289
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "5522dfea7968ce0fbca87245aaf099a6211d7e23",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 292
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "763f4272f5426f2cb52dd90f1f6a490a4ce61418",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 300
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "16d0c877fca994fd1c43bd7f131be967bf4f87b6",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 303
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "16fa3bfaeef78e32910c87baf9060e51490d09ad",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 307
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "d950cd8ef510429f57fb282e4a437321ca86158c",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 316
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "aea3a2f0dd7f8e8655ed2e6cf823ce09d0d537a4",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 321
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "ecca8c8ee1920fbd6387220767bdecf3635a3741",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 324
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b48b7cde42a2442c78c359b776b516d8127006c6",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 330
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2fb4be4f309d24f5064ac5496b8c7f59df0bf966",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 336
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b6acf38d09475b54b197ff0574abc4a7e86c3543",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 342
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "405ac79c3832190ec6bd61bce50e9390a026af72",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "5f3d1367a38ac4e76290f44b6dc26d3555bbabf5",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 354
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "d86912fe91c497e31a4344f459a68586ce096966",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 361
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "1509ab36ebf61526ec42f40016db32b0925e96bc",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 368
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2139b734b30d5642cf104ea814b455bf3f08ca4c",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 375
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "ef73b9a0b786d2e003921cc9967a0a7cd6ae1913",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 382
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2ffc317e2ecc11d98e3e05bf565c6575c4cbfd51",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 388
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "432730c731e24c084f98525d5e133e358dc47b2f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 394
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "3e2f7beb6f6a2204eb1ca56ae8ca7e9602ad13f3",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 398
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "1e2228f0d8e0fc2bc3b8740a8ff3d287006b9a38",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 401
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "1229dc2178fd4b24f6dcb957dbdc4dd4f592e26f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 404
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "5b6b494f771ff69c35ad46e17ec1f7da6eb8696e",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 408
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "fd987a3583a86961bbb3db9e742c93a837cd5bf6",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 413
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "9235bcf425e49c5749d9f52ed67b13717efda795",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 416
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "4439cc784aca22e5cdf33ed6656f5b8b99eaa44d",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 419
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "84cbfec871cf2b178ab90fcc1f442fa369e99a02",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 422
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "84a22bebcaaf61d613e397ca696c152d80aedf67",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 426
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "36149fc78b01f35bca00b832a55a4ea4f21995ae",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 430
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "ea0a3fae2d1694876f309429bfe231935a1e70f4",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 434
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "7e42e0804bd25c31e53b2f0871fa2b3551dc66d9",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 439
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "33e224ae0061fa6dc855702e88cad5e948136009",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 443
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "336d02c1e7195c5237197b5884304099ab15c6ba",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 446
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "b015b77432bd6d255596af7b2f552d8e8c91a7f7",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 450
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "c8fac131170b7bd907b0771e52111a64e940b2a5",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 453
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "befe96270355093c9f7fb4836d62f862ced86d2a",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 456
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "31277df63022917906a65ae6542742dc60d23b54",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 464
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2fbe8ab30e5355ea9d7538aec4d6c869562a0ed8",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 468
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "03816f8548cdb479aadd0ed1610f90e001fef53f",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 471
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "bcc923e3a3bbb5c5c7c2f2b5e17bda91f8076652",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 475
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "f0661775df2ce05e6c49ee07d6003c0e831cda30",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 479
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "9bf200e3147a1d837839d6bf5b649716c1537cbb",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 483
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "4e65da49f5fe5560a904f41bd2519c712c48c151",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 486
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "2cf79101947343f62524a7ab71f313b66d694198",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 491
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "7bedcb999f1cafb154755766dcf512cbc27a2833",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 494
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "8ac9d9448e688e8f94e810f2c49fd9b728bd2041",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 537
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "05b85c0e6305e0bae00683ce171f23f02837787e",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 545
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "95b02c02e7cb902e9603042bf77d2ce3b47d7dd1",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 586
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Base64 High Entropy String",
|
||||||
|
"filename": "pnpm-lock.yaml",
|
||||||
|
"hashed_secret": "9b0596f2945cdeab0d6fb2407275942153f87d9c",
|
||||||
|
"is_verified": false,
|
||||||
|
"line_number": 591
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"generated_at": "2026-05-19T04:40:00Z"
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Duplicate Chess
|
||||||
|
|
||||||
|
A local, single-operator browser sandbox for **duplicate chess** — a four-player
|
||||||
|
chess variant invented by Andrew Freiberg. Four players (North, South, East, West)
|
||||||
|
and four boards (NW, NE, SW, SE); each player controls one colour on two boards and
|
||||||
|
must play the identical move on both. A captured piece leaves a frozen "ghost" twin
|
||||||
|
on the player's other board.
|
||||||
|
|
||||||
|
This tool puts all four boards on one screen as a rotated "compass", enforces the
|
||||||
|
synchronized-move coupling, renders ghosts, shows the move-legality intersection,
|
||||||
|
and detects the endgame.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # run the dev server
|
||||||
|
pnpm build # production build
|
||||||
|
pnpm test # run the engine test suite (vitest)
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm exec svelte-check --tsconfig ./tsconfig.json` typechecks the Svelte/TS code.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-05-19-duplicate-chess-design.md` for the full design,
|
||||||
|
and `docs/superpowers/plans/2026-05-19-duplicate-chess-sandbox.md` for the build plan.
|
||||||
|
|
||||||
|
This is v1 — a local sandbox. Networked multiplayer, AI opponents, and a free
|
||||||
|
position editor are explicitly out of scope (see the spec, §7).
|
||||||
@@ -496,12 +496,12 @@ describe('legalSyncedMoves', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('excludes a move legal on only one of the player\'s boards', () => {
|
it('excludes a move legal on only one of the player\'s boards', () => {
|
||||||
// N e2e4, S e2e4, E e7e5, W d7d5 -> NW black has pe5, NE black has pd5.
|
// N e2e4, S e2e4, E d7d5, W e7e5 -> NW black has pe5 (W's), NE black has pd5 (E's).
|
||||||
const g = new DuplicateGame([
|
const g = new DuplicateGame([
|
||||||
{ player: 'N', from: 'e2', to: 'e4' },
|
{ player: 'N', from: 'e2', to: 'e4' },
|
||||||
{ player: 'S', from: 'e2', to: 'e4' },
|
{ player: 'S', from: 'e2', to: 'e4' },
|
||||||
{ player: 'E', from: 'e7', to: 'e5' },
|
{ player: 'E', from: 'd7', to: 'd5' },
|
||||||
{ player: 'W', from: 'd7', to: 'd5' },
|
{ player: 'W', from: 'e7', to: 'e5' },
|
||||||
]);
|
]);
|
||||||
expect(g.currentPlayer).toBe('N');
|
expect(g.currentPlayer).toBe('N');
|
||||||
const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`);
|
const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`);
|
||||||
@@ -525,8 +525,8 @@ describe('selectionHighlight', () => {
|
|||||||
const g = new DuplicateGame([
|
const g = new DuplicateGame([
|
||||||
{ player: 'N', from: 'e2', to: 'e4' },
|
{ player: 'N', from: 'e2', to: 'e4' },
|
||||||
{ player: 'S', from: 'e2', to: 'e4' },
|
{ player: 'S', from: 'e2', to: 'e4' },
|
||||||
{ player: 'E', from: 'e7', to: 'e5' },
|
{ player: 'E', from: 'd7', to: 'd5' },
|
||||||
{ player: 'W', from: 'd7', to: 'd5' },
|
{ player: 'W', from: 'e7', to: 'e5' },
|
||||||
]);
|
]);
|
||||||
const h = selectionHighlight(g, 'e4'); // North's e4 pawn
|
const h = selectionHighlight(g, 'e4'); // North's e4 pawn
|
||||||
expect(h.boardA).toBe('NW');
|
expect(h.boardA).toBe('NW');
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>vite-tmp</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "duplicate-chess",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"svelte": "^5.55.5",
|
||||||
|
"svelte-check": "^4.4.8",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chess.js": "^1.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1025
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Compass from './lib/Compass.svelte';
|
||||||
|
import Panel from './lib/Panel.svelte';
|
||||||
|
import PromotionDialog from './lib/PromotionDialog.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Duplicate Chess</h1>
|
||||||
|
<p>Local sandbox — operator drives all four players. Click a piece on the
|
||||||
|
glowing boards to see its synchronized-legal moves.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<Compass />
|
||||||
|
<Panel />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<PromotionDialog />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header { padding: 14px 22px; border-bottom: 1px solid #333845; }
|
||||||
|
header h1 { margin: 0; font-size: 17px; }
|
||||||
|
header p { margin: 4px 0 0; font-size: 12px; color: #9aa0aa; }
|
||||||
|
main { display: flex; gap: 22px; padding: 20px 22px; align-items: flex-start; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #15171c;
|
||||||
|
color: #e6e8ec;
|
||||||
|
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { BOARD_IDS, PLAYERS, PLAYER_BOARDS, PLAYER_COLOR, BOARD_PLAYERS, BOARD_ROTATION } from './boards';
|
||||||
|
|
||||||
|
describe('boards constants', () => {
|
||||||
|
it('lists four boards and four players in turn order', () => {
|
||||||
|
expect(BOARD_IDS).toEqual(['NW', 'NE', 'SW', 'SE']);
|
||||||
|
expect(PLAYERS).toEqual(['N', 'S', 'E', 'W']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each player controls exactly two boards', () => {
|
||||||
|
for (const p of PLAYERS) expect(PLAYER_BOARDS[p]).toHaveLength(2);
|
||||||
|
expect(PLAYER_BOARDS.N).toEqual(['NW', 'NE']);
|
||||||
|
expect(PLAYER_BOARDS.W).toEqual(['NW', 'SW']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('board players are consistent with player boards', () => {
|
||||||
|
for (const b of BOARD_IDS) {
|
||||||
|
const { w, b: black } = BOARD_PLAYERS[b];
|
||||||
|
expect(PLAYER_BOARDS[w]).toContain(b);
|
||||||
|
expect(PLAYER_BOARDS[black]).toContain(b);
|
||||||
|
expect(PLAYER_COLOR[w]).toBe('w');
|
||||||
|
expect(PLAYER_COLOR[black]).toBe('b');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a rotation for every board', () => {
|
||||||
|
expect(BOARD_ROTATION).toEqual({ NW: 225, NE: 135, SW: 315, SE: 45 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { BoardId, Player, Color } from './types';
|
||||||
|
|
||||||
|
export const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
|
||||||
|
|
||||||
|
/** Turn order. */
|
||||||
|
export const PLAYERS: Player[] = ['N', 'S', 'E', 'W'];
|
||||||
|
|
||||||
|
/** The two boards each player controls (order is stable: [boardA, boardB]). */
|
||||||
|
export const PLAYER_BOARDS: Record<Player, [BoardId, BoardId]> = {
|
||||||
|
N: ['NW', 'NE'],
|
||||||
|
S: ['SW', 'SE'],
|
||||||
|
E: ['NE', 'SE'],
|
||||||
|
W: ['NW', 'SW'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The colour each player plays on both their boards. */
|
||||||
|
export const PLAYER_COLOR: Record<Player, Color> = {
|
||||||
|
N: 'w', S: 'w', E: 'b', W: 'b',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The white and black player of each board. */
|
||||||
|
export const BOARD_PLAYERS: Record<BoardId, { w: Player; b: Player }> = {
|
||||||
|
NW: { w: 'N', b: 'W' },
|
||||||
|
NE: { w: 'N', b: 'E' },
|
||||||
|
SW: { w: 'S', b: 'W' },
|
||||||
|
SE: { w: 'S', b: 'E' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compass rotation in degrees for rendering each board (see spec §5.1). */
|
||||||
|
export const BOARD_ROTATION: Record<BoardId, number> = {
|
||||||
|
NW: 225, NE: 135, SW: 315, SE: 45,
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DuplicateGame } from './game';
|
||||||
|
import { playSymmetric } from './test-helpers';
|
||||||
|
import { evaluateStatus } from './endgame';
|
||||||
|
|
||||||
|
describe('evaluateStatus', () => {
|
||||||
|
it('reports an ongoing game at the start', () => {
|
||||||
|
const s = evaluateStatus(new DuplicateGame());
|
||||||
|
expect(s.state).toBe('playing');
|
||||||
|
expect(s.checks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a double-board checkmate (Fool\'s mate, played symmetrically)', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
playSymmetric(g, [
|
||||||
|
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
|
||||||
|
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
|
||||||
|
]);
|
||||||
|
expect(g.currentPlayer).toBe('N'); // North (White) is mated
|
||||||
|
const s = evaluateStatus(g);
|
||||||
|
expect(s.state).toBe('checkmate');
|
||||||
|
expect(s.checks.sort()).toEqual(['NE', 'NW']);
|
||||||
|
expect(s.result).toEqual({ N: 'loss', S: 'draw', E: 'win', W: 'win' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects threefold repetition of the whole system', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
const cycle: Array<[{ from: string; to: string }, { from: string; to: string }]> = [
|
||||||
|
[{ from: 'g1', to: 'f3' }, { from: 'g8', to: 'f6' }],
|
||||||
|
[{ from: 'f3', to: 'g1' }, { from: 'f6', to: 'g8' }],
|
||||||
|
];
|
||||||
|
playSymmetric(g, cycle); // back to start (occurrence 2)
|
||||||
|
playSymmetric(g, cycle); // back to start (occurrence 3)
|
||||||
|
const s = evaluateStatus(g);
|
||||||
|
expect(s.state).toBe('draw');
|
||||||
|
expect(s.reason).toBe('threefold');
|
||||||
|
expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects a stalemate as an all-draw game end (provisional rule)', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
// The known fastest stalemate, played symmetrically on all four boards.
|
||||||
|
playSymmetric(g, [
|
||||||
|
[{ from: 'e2', to: 'e3' }, { from: 'a7', to: 'a5' }],
|
||||||
|
[{ from: 'd1', to: 'h5' }, { from: 'a8', to: 'a6' }],
|
||||||
|
[{ from: 'h5', to: 'a5' }, { from: 'h7', to: 'h5' }],
|
||||||
|
[{ from: 'a5', to: 'c7' }, { from: 'a6', to: 'h6' }],
|
||||||
|
[{ from: 'h2', to: 'h4' }, { from: 'f7', to: 'f6' }],
|
||||||
|
[{ from: 'c7', to: 'd7' }, { from: 'e8', to: 'f7' }],
|
||||||
|
[{ from: 'd7', to: 'b7' }, { from: 'd8', to: 'd3' }],
|
||||||
|
[{ from: 'b7', to: 'b8' }, { from: 'd3', to: 'h7' }],
|
||||||
|
[{ from: 'b8', to: 'c8' }, { from: 'f7', to: 'g6' }],
|
||||||
|
[{ from: 'c8', to: 'e6' }], // no black reply — Black is stalemated
|
||||||
|
]);
|
||||||
|
expect(g.currentPlayer).toBe('E'); // a Black player, with no move
|
||||||
|
const s = evaluateStatus(g);
|
||||||
|
expect(s.state).toBe('stalemate');
|
||||||
|
expect(s.reason).toBe('stalemate');
|
||||||
|
expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { DuplicateGame } from './game';
|
||||||
|
import type { GameStatus, GameResult, BoardId } from './types';
|
||||||
|
import { PLAYERS, PLAYER_BOARDS, BOARD_PLAYERS } from './boards';
|
||||||
|
import { legalSyncedMoves } from './legality';
|
||||||
|
|
||||||
|
/** PROVISIONAL (spec §6): the 50-move rule fires after this many rounds. */
|
||||||
|
const FIFTY_MOVE_ROUNDS = 50;
|
||||||
|
const FIFTY_MOVE_PLIES = FIFTY_MOVE_ROUNDS * 4;
|
||||||
|
|
||||||
|
function allDraw(): GameResult {
|
||||||
|
return { N: 'draw', S: 'draw', E: 'draw', W: 'draw' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate the game from the perspective of the player to move. */
|
||||||
|
export function evaluateStatus(game: DuplicateGame): GameStatus {
|
||||||
|
const player = game.currentPlayer;
|
||||||
|
const [a, b] = PLAYER_BOARDS[player];
|
||||||
|
|
||||||
|
const checks: BoardId[] = [];
|
||||||
|
if (game.boards[a].inCheck()) checks.push(a);
|
||||||
|
if (game.boards[b].inCheck()) checks.push(b);
|
||||||
|
|
||||||
|
const synced = legalSyncedMoves(game);
|
||||||
|
|
||||||
|
if (synced.length === 0) {
|
||||||
|
if (checks.length > 0) {
|
||||||
|
// Checkmate. PROVISIONAL (spec §6): every opponent delivering a check wins.
|
||||||
|
const winners = checks.map((board) =>
|
||||||
|
BOARD_PLAYERS[board].w === player
|
||||||
|
? BOARD_PLAYERS[board].b
|
||||||
|
: BOARD_PLAYERS[board].w,
|
||||||
|
);
|
||||||
|
const result = {} as GameResult;
|
||||||
|
for (const p of PLAYERS) {
|
||||||
|
result[p] = p === player ? 'loss' : winners.includes(p) ? 'win' : 'draw';
|
||||||
|
}
|
||||||
|
return { state: 'checkmate', result, checks };
|
||||||
|
}
|
||||||
|
// PROVISIONAL (spec §6): a no-synchronized-move stalemate ends the game, all draw.
|
||||||
|
return { state: 'stalemate', result: allDraw(), reason: 'stalemate', checks };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.repetitionCount() >= 3) {
|
||||||
|
return { state: 'draw', result: allDraw(), reason: 'threefold', checks };
|
||||||
|
}
|
||||||
|
if (game.pliesSinceProgress >= FIFTY_MOVE_PLIES) {
|
||||||
|
return { state: 'draw', result: allDraw(), reason: 'fifty-move', checks };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: 'playing', checks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DuplicateGame } from './game';
|
||||||
|
|
||||||
|
const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR';
|
||||||
|
|
||||||
|
function placement(fen: string) {
|
||||||
|
return fen.split(' ')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DuplicateGame', () => {
|
||||||
|
it('starts with four boards in the standard position, North to move', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
expect(g.ply).toBe(0);
|
||||||
|
expect(g.currentPlayer).toBe('N');
|
||||||
|
for (const id of ['NW', 'NE', 'SW', 'SE'] as const) {
|
||||||
|
expect(placement(g.boards[id].fen())).toBe(START);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a synchronized move to the current player's two boards only", () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
g.applyMove({ from: 'e2', to: 'e4' }); // North
|
||||||
|
expect(g.ply).toBe(1);
|
||||||
|
expect(g.currentPlayer).toBe('S');
|
||||||
|
expect(placement(g.boards.NW.fen())).toContain('4P3'); // pawn advanced
|
||||||
|
expect(placement(g.boards.NE.fen())).toContain('4P3');
|
||||||
|
expect(placement(g.boards.SW.fen())).toBe(START); // untouched
|
||||||
|
expect(placement(g.boards.SE.fen())).toBe(START);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycles the current player N -> S -> E -> W -> N', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
g.applyMove({ from: 'e2', to: 'e4' }); // N
|
||||||
|
g.applyMove({ from: 'e2', to: 'e4' }); // S
|
||||||
|
g.applyMove({ from: 'e7', to: 'e5' }); // E
|
||||||
|
g.applyMove({ from: 'e7', to: 'e5' }); // W
|
||||||
|
expect(g.currentPlayer).toBe('N');
|
||||||
|
expect(g.ply).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on a move not legal on both of the player's boards", () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
expect(() => g.applyMove({ from: 'e2', to: 'e5' })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo removes the last move and restores the position', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
g.applyMove({ from: 'e2', to: 'e4' });
|
||||||
|
g.undo();
|
||||||
|
expect(g.ply).toBe(0);
|
||||||
|
expect(g.currentPlayer).toBe('N');
|
||||||
|
expect(placement(g.boards.NW.fen())).toBe(START);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds from a history array passed to the constructor', () => {
|
||||||
|
const g = new DuplicateGame([
|
||||||
|
{ player: 'N', from: 'e2', to: 'e4' },
|
||||||
|
{ player: 'S', from: 'e2', to: 'e4' },
|
||||||
|
]);
|
||||||
|
expect(g.ply).toBe(2);
|
||||||
|
expect(g.currentPlayer).toBe('E');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the progress clock on a pawn move or capture', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
g.applyMove({ from: 'e2', to: 'e4' }); // pawn move -> clock stays 0
|
||||||
|
expect(g.pliesSinceProgress).toBe(0);
|
||||||
|
g.applyMove({ from: 'e2', to: 'e4' }); // pawn move
|
||||||
|
expect(g.pliesSinceProgress).toBe(0);
|
||||||
|
g.applyMove({ from: 'g8', to: 'f6' }); // knight move -> clock increments
|
||||||
|
expect(g.pliesSinceProgress).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Chess } from 'chess.js';
|
||||||
|
import type { BoardId, Player, SyncMove, HistoryEntry } from './types';
|
||||||
|
import { BOARD_IDS, PLAYERS, PLAYER_BOARDS } from './boards';
|
||||||
|
|
||||||
|
/** A chess.js move result has these fields we rely on. */
|
||||||
|
interface ChessMoveResult {
|
||||||
|
piece: string;
|
||||||
|
captured?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DuplicateGame {
|
||||||
|
readonly boards: Record<BoardId, Chess>;
|
||||||
|
readonly history: HistoryEntry[] = [];
|
||||||
|
|
||||||
|
/** Plies since the last capture or pawn move on any board (for the 50-move rule). */
|
||||||
|
pliesSinceProgress = 0;
|
||||||
|
/** Repetition keys of the whole 4-board system, one per position incl. the start. */
|
||||||
|
readonly repetitionKeys: string[] = [];
|
||||||
|
|
||||||
|
constructor(history: HistoryEntry[] = []) {
|
||||||
|
this.boards = {
|
||||||
|
NW: new Chess(), NE: new Chess(), SW: new Chess(), SE: new Chess(),
|
||||||
|
};
|
||||||
|
this.repetitionKeys.push(this.systemKey());
|
||||||
|
for (const entry of history) this.applyMove(entry, entry.player);
|
||||||
|
}
|
||||||
|
|
||||||
|
get ply(): number {
|
||||||
|
return this.history.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentPlayer(): Player {
|
||||||
|
return PLAYERS[this.history.length % 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `move` is a legal chess move on `board` in the current position. */
|
||||||
|
isLegalOnBoard(board: BoardId, move: SyncMove): boolean {
|
||||||
|
return this.boards[board].moves({ verbose: true }).some(
|
||||||
|
(m) =>
|
||||||
|
m.from === move.from &&
|
||||||
|
m.to === move.to &&
|
||||||
|
(m.promotion ?? undefined) === (move.promotion ?? undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply one synchronized move to a player's two boards. Throws if illegal on either. */
|
||||||
|
applyMove(move: SyncMove, player: Player = this.currentPlayer): void {
|
||||||
|
const [a, b] = PLAYER_BOARDS[player];
|
||||||
|
if (!this.isLegalOnBoard(a, move) || !this.isLegalOnBoard(b, move)) {
|
||||||
|
throw new Error(
|
||||||
|
`Illegal synchronized move ${move.from}${move.to} for ${player}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ra = this.boards[a].move(move) as unknown as ChessMoveResult;
|
||||||
|
const rb = this.boards[b].move(move) as unknown as ChessMoveResult;
|
||||||
|
this.history.push({ ...move, player });
|
||||||
|
const progress = isProgress(ra) || isProgress(rb);
|
||||||
|
this.pliesSinceProgress = progress ? 0 : this.pliesSinceProgress + 1;
|
||||||
|
this.repetitionKeys.push(this.systemKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the last move by replaying the truncated history. */
|
||||||
|
undo(): void {
|
||||||
|
if (this.history.length === 0) return;
|
||||||
|
const replay = this.history.slice(0, -1);
|
||||||
|
for (const id of BOARD_IDS) this.boards[id].reset();
|
||||||
|
this.history.length = 0;
|
||||||
|
this.pliesSinceProgress = 0;
|
||||||
|
this.repetitionKeys.length = 0;
|
||||||
|
this.repetitionKeys.push(this.systemKey());
|
||||||
|
for (const e of replay) this.applyMove(e, e.player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A repetition key for the whole system: each board's placement+castling+ep, plus the side to move. */
|
||||||
|
systemKey(): string {
|
||||||
|
return (
|
||||||
|
BOARD_IDS.map((id) => {
|
||||||
|
const parts = this.boards[id].fen().split(' ');
|
||||||
|
return `${parts[0]}${parts[2]}${parts[3]}`;
|
||||||
|
}).join('|') + ':' + this.currentPlayer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many times the current system position has occurred. */
|
||||||
|
repetitionCount(): number {
|
||||||
|
const current = this.repetitionKeys[this.repetitionKeys.length - 1];
|
||||||
|
return this.repetitionKeys.filter((k) => k === current).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProgress(result: ChessMoveResult): boolean {
|
||||||
|
return result.piece === 'p' || result.captured != null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DuplicateGame } from './game';
|
||||||
|
import { playSymmetric } from './test-helpers';
|
||||||
|
import { ghosts } from './ghosts';
|
||||||
|
|
||||||
|
describe('ghosts', () => {
|
||||||
|
it('reports no ghosts at the start', () => {
|
||||||
|
expect(ghosts(new DuplicateGame())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forms a ghost when a piece is captured on one board but not its twin', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
// Symmetric opening so all four boards stay identical...
|
||||||
|
playSymmetric(g, [
|
||||||
|
[{ from: 'e2', to: 'e4' }, { from: 'e7', to: 'e5' }],
|
||||||
|
[{ from: 'g1', to: 'f3' }, { from: 'b8', to: 'c6' }],
|
||||||
|
]);
|
||||||
|
// ...then North & South each play Nxe5 (capturing the e5 pawn),
|
||||||
|
// and East captures that knight only on its boards (NE, SE).
|
||||||
|
g.applyMove({ from: 'f3', to: 'e5' }); // N: Nf3xe5 on NW, NE
|
||||||
|
g.applyMove({ from: 'f3', to: 'e5' }); // S: Nf3xe5 on SW, SE
|
||||||
|
g.applyMove({ from: 'c6', to: 'e5' }); // E: Nc6xe5 on NE, SE — captures the white knight
|
||||||
|
// North's knight on e5 survives on NW but was captured on NE -> NW/e5 is a ghost.
|
||||||
|
// South's knight on e5 survives on SW but was captured on SE -> SW/e5 is a ghost.
|
||||||
|
const result = ghosts(g).sort((x, y) => (x.board < y.board ? -1 : 1));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ board: 'NW', square: 'e5' },
|
||||||
|
{ board: 'SW', square: 'e5' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { DuplicateGame } from './game';
|
||||||
|
import type { GhostMarker, BoardId, Color, Square } from './types';
|
||||||
|
import { PLAYERS, PLAYER_BOARDS, PLAYER_COLOR } from './boards';
|
||||||
|
|
||||||
|
/** Squares occupied by a piece of `color` on `board`. */
|
||||||
|
function colorSquares(game: DuplicateGame, board: BoardId, color: Color): Set<Square> {
|
||||||
|
const set = new Set<Square>();
|
||||||
|
for (const row of game.boards[board].board()) {
|
||||||
|
for (const cell of row) {
|
||||||
|
if (cell && cell.color === color) set.add(cell.square);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ghosts across all four players. A player's non-ghost pieces always occupy
|
||||||
|
* identical squares on both their boards (they move in lockstep), so a piece is
|
||||||
|
* a ghost iff the player's other board has no same-colour piece on that square.
|
||||||
|
*/
|
||||||
|
export function ghosts(game: DuplicateGame): GhostMarker[] {
|
||||||
|
const markers: GhostMarker[] = [];
|
||||||
|
for (const player of PLAYERS) {
|
||||||
|
const [a, b] = PLAYER_BOARDS[player];
|
||||||
|
const color = PLAYER_COLOR[player];
|
||||||
|
const sqA = colorSquares(game, a, color);
|
||||||
|
const sqB = colorSquares(game, b, color);
|
||||||
|
for (const sq of sqA) if (!sqB.has(sq)) markers.push({ board: a, square: sq });
|
||||||
|
for (const sq of sqB) if (!sqA.has(sq)) markers.push({ board: b, square: sq });
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DuplicateGame } from './game';
|
||||||
|
import { playSymmetric } from './test-helpers';
|
||||||
|
import { legalSyncedMoves } from './legality';
|
||||||
|
import { ghosts } from './ghosts';
|
||||||
|
import { evaluateStatus } from './endgame';
|
||||||
|
import { serialize, deserialize } from './notation';
|
||||||
|
|
||||||
|
describe('integration: a scripted game played to checkmate', () => {
|
||||||
|
it('plays Fool\'s mate, stays consistent throughout, and ends correctly', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
|
||||||
|
// The game is live and ongoing until the mate.
|
||||||
|
expect(evaluateStatus(g).state).toBe('playing');
|
||||||
|
expect(legalSyncedMoves(g).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
playSymmetric(g, [
|
||||||
|
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
|
||||||
|
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No captures occurred, so there are no ghosts.
|
||||||
|
expect(ghosts(g)).toEqual([]);
|
||||||
|
|
||||||
|
// North is checkmated.
|
||||||
|
const status = evaluateStatus(g);
|
||||||
|
expect(status.state).toBe('checkmate');
|
||||||
|
expect(status.result?.N).toBe('loss');
|
||||||
|
expect(legalSyncedMoves(g)).toEqual([]);
|
||||||
|
|
||||||
|
// The game round-trips through save/load and reproduces the same outcome.
|
||||||
|
const restored = new DuplicateGame(deserialize(serialize(g.history)));
|
||||||
|
expect(evaluateStatus(restored).state).toBe('checkmate');
|
||||||
|
expect(restored.history).toEqual(g.history);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo from the mated position restores a playable game', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
playSymmetric(g, [
|
||||||
|
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
|
||||||
|
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
|
||||||
|
]);
|
||||||
|
g.undo(); // take back W's d8h4
|
||||||
|
expect(evaluateStatus(g).state).toBe('playing');
|
||||||
|
expect(g.currentPlayer).toBe('W');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DuplicateGame } from './game';
|
||||||
|
import { legalSyncedMoves, selectionHighlight } from './legality';
|
||||||
|
|
||||||
|
describe('legalSyncedMoves', () => {
|
||||||
|
it('returns all 20 white opening moves when a player\'s two boards are identical', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
expect(legalSyncedMoves(g)).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes a move legal on only one of the player\'s boards', () => {
|
||||||
|
// N e2e4, S e2e4, W e7e5, E d7d5 -> NW black has pe5, NE black has pd5.
|
||||||
|
const g = new DuplicateGame([
|
||||||
|
{ player: 'N', from: 'e2', to: 'e4' },
|
||||||
|
{ player: 'S', from: 'e2', to: 'e4' },
|
||||||
|
{ player: 'E', from: 'd7', to: 'd5' },
|
||||||
|
{ player: 'W', from: 'e7', to: 'e5' },
|
||||||
|
]);
|
||||||
|
expect(g.currentPlayer).toBe('N');
|
||||||
|
const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`);
|
||||||
|
// e4-d5 is a capture on NE but illegal on NW (d5 empty there): not synced.
|
||||||
|
expect(keys).not.toContain('e4d5');
|
||||||
|
// e4-e5 is legal on NE but blocked on NW (black pawn on e5): not synced.
|
||||||
|
expect(keys).not.toContain('e4e5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectionHighlight', () => {
|
||||||
|
it('marks every destination playable when the two boards agree', () => {
|
||||||
|
const g = new DuplicateGame();
|
||||||
|
const h = selectionHighlight(g, 'e2');
|
||||||
|
expect(h.playable.sort()).toEqual(['e3', 'e4']);
|
||||||
|
expect(h.onlyA).toEqual([]);
|
||||||
|
expect(h.onlyB).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits destinations into playable vs board-local-only on divergence', () => {
|
||||||
|
const g = new DuplicateGame([
|
||||||
|
{ player: 'N', from: 'e2', to: 'e4' },
|
||||||
|
{ player: 'S', from: 'e2', to: 'e4' },
|
||||||
|
{ player: 'E', from: 'd7', to: 'd5' },
|
||||||
|
{ player: 'W', from: 'e7', to: 'e5' },
|
||||||
|
]);
|
||||||
|
const h = selectionHighlight(g, 'e4'); // North's e4 pawn
|
||||||
|
expect(h.boardA).toBe('NW');
|
||||||
|
expect(h.boardB).toBe('NE');
|
||||||
|
expect(h.playable).toEqual([]); // nothing legal on both
|
||||||
|
expect(h.onlyA).toEqual([]); // e4 is blocked on NW
|
||||||
|
expect(h.onlyB.sort()).toEqual(['d5', 'e5']); // capture + advance on NE only
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { DuplicateGame } from './game';
|
||||||
|
import type { SyncMove, Square, BoardId, PromotionPiece } from './types';
|
||||||
|
import { PLAYER_BOARDS } from './boards';
|
||||||
|
|
||||||
|
function key(m: { from: string; to: string; promotion?: string }): string {
|
||||||
|
return `${m.from}${m.to}${m.promotion ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every synchronized-legal move for the player to move (the intersection). */
|
||||||
|
export function legalSyncedMoves(game: DuplicateGame): SyncMove[] {
|
||||||
|
const [a, b] = PLAYER_BOARDS[game.currentPlayer];
|
||||||
|
const movesA = game.boards[a].moves({ verbose: true });
|
||||||
|
const keysB = new Set(game.boards[b].moves({ verbose: true }).map(key));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: SyncMove[] = [];
|
||||||
|
for (const m of movesA) {
|
||||||
|
const k = key(m);
|
||||||
|
if (keysB.has(k) && !seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
result.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
promotion: (m.promotion as PromotionPiece) || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionHighlight {
|
||||||
|
/** The current player's first board. */
|
||||||
|
boardA: BoardId;
|
||||||
|
/** The current player's second board. */
|
||||||
|
boardB: BoardId;
|
||||||
|
/** Destinations legal on BOTH boards (actually playable). */
|
||||||
|
playable: Square[];
|
||||||
|
/** Destinations legal on board A only. */
|
||||||
|
onlyA: Square[];
|
||||||
|
/** Destinations legal on board B only. */
|
||||||
|
onlyB: Square[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triple-highlight data for the current player's piece grabbed at `from`. */
|
||||||
|
export function selectionHighlight(
|
||||||
|
game: DuplicateGame,
|
||||||
|
from: Square,
|
||||||
|
): SelectionHighlight {
|
||||||
|
const [a, b] = PLAYER_BOARDS[game.currentPlayer];
|
||||||
|
const destA = new Set(
|
||||||
|
game.boards[a].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to),
|
||||||
|
);
|
||||||
|
const destB = new Set(
|
||||||
|
game.boards[b].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to),
|
||||||
|
);
|
||||||
|
const playable: Square[] = [];
|
||||||
|
const onlyA: Square[] = [];
|
||||||
|
const onlyB: Square[] = [];
|
||||||
|
for (const sq of destA) (destB.has(sq) ? playable : onlyA).push(sq);
|
||||||
|
for (const sq of destB) if (!destA.has(sq)) onlyB.push(sq);
|
||||||
|
return { boardA: a, boardB: b, playable, onlyA, onlyB };
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toCoordinate, serialize, deserialize } from './notation';
|
||||||
|
import type { HistoryEntry } from './types';
|
||||||
|
|
||||||
|
describe('notation', () => {
|
||||||
|
it('renders a move as a coordinate token', () => {
|
||||||
|
expect(toCoordinate({ from: 'e2', to: 'e4' })).toBe('e2e4');
|
||||||
|
expect(toCoordinate({ from: 'e7', to: 'e8', promotion: 'q' })).toBe('e7e8q');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a game through serialize/deserialize', () => {
|
||||||
|
const history: HistoryEntry[] = [
|
||||||
|
{ player: 'N', from: 'e2', to: 'e4' },
|
||||||
|
{ player: 'S', from: 'e2', to: 'e4' },
|
||||||
|
];
|
||||||
|
const restored = deserialize(serialize(history));
|
||||||
|
expect(restored).toEqual(history);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a file that is not a duplicate-chess save', () => {
|
||||||
|
expect(() => deserialize('{"variant":"chess","version":1,"moves":[]}')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsupported save version', () => {
|
||||||
|
expect(() => deserialize('{"variant":"duplicate-chess","version":99,"moves":[]}')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { SyncMove, HistoryEntry } from './types';
|
||||||
|
|
||||||
|
/** Coordinate notation, e.g. "e2e4" or "e7e8q". */
|
||||||
|
export function toCoordinate(move: SyncMove): string {
|
||||||
|
return `${move.from}${move.to}${move.promotion ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedGame {
|
||||||
|
variant: 'duplicate-chess';
|
||||||
|
version: 1;
|
||||||
|
moves: HistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serialize(history: HistoryEntry[]): string {
|
||||||
|
const data: SavedGame = { variant: 'duplicate-chess', version: 1, moves: history };
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserialize(json: string): HistoryEntry[] {
|
||||||
|
const data = JSON.parse(json) as Partial<SavedGame>;
|
||||||
|
if (data.variant !== 'duplicate-chess' || !Array.isArray(data.moves)) {
|
||||||
|
throw new Error('Not a duplicate-chess save file');
|
||||||
|
}
|
||||||
|
if (data.version !== 1) {
|
||||||
|
throw new Error('Unsupported save version');
|
||||||
|
}
|
||||||
|
return data.moves;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { DuplicateGame } from './game';
|
||||||
|
import type { SyncMove } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a list of [whiteMove, blackMove?] underlying ply-pairs symmetrically:
|
||||||
|
* North then South play the white move, East then West play the black move.
|
||||||
|
* While every move is symmetric all four boards stay identical, so each board
|
||||||
|
* behaves as an ordinary chess game — useful for reaching ordinary checkmate /
|
||||||
|
* stalemate / repetition positions. Omit blackMove for a final unanswered white move.
|
||||||
|
*/
|
||||||
|
export function playSymmetric(
|
||||||
|
game: DuplicateGame,
|
||||||
|
pairs: Array<[SyncMove, SyncMove?]>,
|
||||||
|
): void {
|
||||||
|
for (const [white, black] of pairs) {
|
||||||
|
game.applyMove(white); // N
|
||||||
|
game.applyMove(white); // S
|
||||||
|
if (black) {
|
||||||
|
game.applyMove(black); // E
|
||||||
|
game.applyMove(black); // W
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type BoardId = 'NW' | 'NE' | 'SW' | 'SE';
|
||||||
|
export type Player = 'N' | 'S' | 'E' | 'W';
|
||||||
|
export type Color = 'w' | 'b';
|
||||||
|
export type Square = string;
|
||||||
|
export type PromotionPiece = 'q' | 'r' | 'b' | 'n';
|
||||||
|
|
||||||
|
export interface SyncMove {
|
||||||
|
from: Square;
|
||||||
|
to: Square;
|
||||||
|
promotion?: PromotionPiece;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry extends SyncMove {
|
||||||
|
player: Player;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GhostMarker {
|
||||||
|
board: BoardId;
|
||||||
|
square: Square;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerResult = 'win' | 'draw' | 'loss';
|
||||||
|
export type GameResult = Record<Player, PlayerResult>;
|
||||||
|
export type GameState = 'playing' | 'checkmate' | 'stalemate' | 'draw';
|
||||||
|
export type DrawReason = 'stalemate' | 'threefold' | 'fifty-move' | 'manual';
|
||||||
|
|
||||||
|
export interface GameStatus {
|
||||||
|
state: GameState;
|
||||||
|
/** Present when state !== 'playing'. */
|
||||||
|
result?: GameResult;
|
||||||
|
/** Present for a draw/stalemate. */
|
||||||
|
reason?: DrawReason;
|
||||||
|
/** Boards on which the player to move is currently in check. */
|
||||||
|
checks: BoardId[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BoardId, Player, Square } from '../engine/types';
|
||||||
|
import type { SelectionHighlight } from '../engine/legality';
|
||||||
|
import { BOARD_ROTATION, BOARD_PLAYERS } from '../engine/boards';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: BoardId;
|
||||||
|
fen: string;
|
||||||
|
/** Player colours, e.g. { N:'#4a90d9', ... }. */
|
||||||
|
colors: Record<Player, string>;
|
||||||
|
ghosts: Square[];
|
||||||
|
/** Highlight for this board, or null if no piece is grabbed / not active. */
|
||||||
|
highlight: { playable: Square[]; local: Square[]; selected: Square | null } | null;
|
||||||
|
active: boolean;
|
||||||
|
onSquare: (square: Square) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, fen, colors, ghosts, highlight, active, onSquare }: Props = $props();
|
||||||
|
|
||||||
|
const FILES = 'abcdefgh';
|
||||||
|
const GLYPH: Record<string, string> = {
|
||||||
|
k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Cell { square: Square; piece: { glyph: string; color: string } | null; }
|
||||||
|
|
||||||
|
let cells = $derived.by<Cell[]>(() => {
|
||||||
|
const placement = fen.split(' ')[0];
|
||||||
|
const white = colors[BOARD_PLAYERS[id].w];
|
||||||
|
const black = colors[BOARD_PLAYERS[id].b];
|
||||||
|
const map: Record<string, { glyph: string; color: string }> = {};
|
||||||
|
placement.split('/').forEach((row, ri) => {
|
||||||
|
const rank = 8 - ri;
|
||||||
|
let file = 0;
|
||||||
|
for (const ch of row) {
|
||||||
|
if (/\d/.test(ch)) { file += Number(ch); continue; }
|
||||||
|
const isWhite = ch === ch.toUpperCase();
|
||||||
|
map[`${FILES[file]}${rank}`] = {
|
||||||
|
glyph: GLYPH[ch.toLowerCase()],
|
||||||
|
color: isWhite ? white : black,
|
||||||
|
};
|
||||||
|
file += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const out: Cell[] = [];
|
||||||
|
for (let rank = 8; rank >= 1; rank--) {
|
||||||
|
for (let f = 0; f < 8; f++) {
|
||||||
|
const square = `${FILES[f]}${rank}`;
|
||||||
|
out.push({ square, piece: map[square] ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function classes(cell: Cell, index: number): string {
|
||||||
|
const dark = (index + Math.floor(index / 8)) % 2 === 1;
|
||||||
|
const hl = highlight;
|
||||||
|
const list = ['sq', dark ? 'dark' : 'light'];
|
||||||
|
if (ghosts.includes(cell.square)) list.push('ghost-sq');
|
||||||
|
if (hl?.playable.includes(cell.square)) list.push(cell.piece ? 'play occ' : 'play');
|
||||||
|
if (hl?.local.includes(cell.square)) list.push('local');
|
||||||
|
if (hl?.selected === cell.square) list.push('selected');
|
||||||
|
return list.join(' ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="board" class:active style="--rot:{BOARD_ROTATION[id]}deg">
|
||||||
|
{#each cells as cell, i (cell.square)}
|
||||||
|
<button class={classes(cell, i)} onclick={() => onSquare(cell.square)} aria-label={cell.square}>
|
||||||
|
{#if cell.piece}
|
||||||
|
<span class="pc" class:ghost={ghosts.includes(cell.square)}
|
||||||
|
style="color:{cell.piece.color}">{cell.piece.glyph}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, var(--sq, 34px));
|
||||||
|
grid-template-rows: repeat(8, var(--sq, 34px));
|
||||||
|
transform: rotate(var(--rot));
|
||||||
|
border: 1px solid #20232b;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.board.active { box-shadow: 0 0 0 3px var(--glow, #4a90d9), 0 0 20px 2px var(--glow, #4a90d9); }
|
||||||
|
.sq {
|
||||||
|
position: relative; padding: 0; border: 0; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.sq.light { background: #cabf9f; }
|
||||||
|
.sq.dark { background: #7d6f55; }
|
||||||
|
.pc {
|
||||||
|
font-size: calc(var(--sq, 34px) * 0.76); line-height: 1;
|
||||||
|
text-shadow: -1px -1px 0 #15171c, 1px -1px 0 #15171c,
|
||||||
|
-1px 1px 0 #15171c, 1px 1px 0 #15171c;
|
||||||
|
}
|
||||||
|
.pc.ghost { opacity: 0.42; }
|
||||||
|
.ghost-sq { outline: 2px dashed #888; outline-offset: -2px; }
|
||||||
|
.sq.play::after, .sq.local::after {
|
||||||
|
content: ''; position: absolute; border-radius: 50%;
|
||||||
|
width: 32%; height: 32%;
|
||||||
|
}
|
||||||
|
.sq.play::after { background: #46c24f; box-shadow: 0 0 7px #46c24f; }
|
||||||
|
.sq.play.occ::after {
|
||||||
|
width: 84%; height: 84%; background: transparent;
|
||||||
|
border: 3px solid #46c24f; box-shadow: 0 0 7px #46c24f;
|
||||||
|
}
|
||||||
|
.sq.local::after { background: transparent; border: 2px dashed #9aa0aa; }
|
||||||
|
.sq.selected { outline: 3px solid #3fd9d9; outline-offset: -3px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Board from './Board.svelte';
|
||||||
|
import { gameStore } from './stores/game.svelte';
|
||||||
|
import { PLAYER_BOARDS } from '../engine/boards';
|
||||||
|
import type { BoardId, Player, Square } from '../engine/types';
|
||||||
|
|
||||||
|
const COLORS: Record<Player, string> = {
|
||||||
|
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
|
||||||
|
};
|
||||||
|
// Board centre positions inside the 744x744 compass (see spec §5.1).
|
||||||
|
const POS: Record<BoardId, { left: number; top: number }> = {
|
||||||
|
NW: { left: 200, top: 200 }, NE: { left: 544, top: 200 },
|
||||||
|
SW: { left: 200, top: 544 }, SE: { left: 544, top: 544 },
|
||||||
|
};
|
||||||
|
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
|
||||||
|
|
||||||
|
let view = $derived(gameStore.view);
|
||||||
|
let active = $derived(gameStore.activeBoards);
|
||||||
|
|
||||||
|
/** Ghost squares for a given board. */
|
||||||
|
function ghostsFor(id: BoardId): Square[] {
|
||||||
|
return view.ghosts.filter((g) => g.board === id).map((g) => g.square);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Highlight payload for a given board, or null. */
|
||||||
|
function highlightFor(id: BoardId) {
|
||||||
|
const h = gameStore.highlight;
|
||||||
|
if (h === null) return null;
|
||||||
|
if (id !== h.boardA && id !== h.boardB) return null;
|
||||||
|
const local = id === h.boardA ? h.onlyA : h.onlyB;
|
||||||
|
const selectedHere = active.includes(id) ? gameStore.selected : null;
|
||||||
|
return { playable: h.playable, local, selected: selectedHere };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSquare(id: BoardId, square: Square): void {
|
||||||
|
if (gameStore.isScrubbing) return;
|
||||||
|
if (!active.includes(id)) return; // only the player-to-move's boards are interactive
|
||||||
|
if (gameStore.selected === null) {
|
||||||
|
gameStore.select(square);
|
||||||
|
} else if (gameStore.highlight?.playable.includes(square)) {
|
||||||
|
gameStore.commitTo(square);
|
||||||
|
} else {
|
||||||
|
gameStore.select(square); // re-grab or cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="compass" style="--glow:{COLORS[view.currentPlayer]}">
|
||||||
|
{#each BOARD_IDS as id (id)}
|
||||||
|
<div class="slot" style="left:{POS[id].left}px; top:{POS[id].top}px;">
|
||||||
|
<Board
|
||||||
|
{id}
|
||||||
|
fen={view.fen[id]}
|
||||||
|
colors={COLORS}
|
||||||
|
ghosts={ghostsFor(id)}
|
||||||
|
highlight={highlightFor(id)}
|
||||||
|
active={active.includes(id)}
|
||||||
|
onSquare={(sq) => handleSquare(id, sq)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="plabel" class:on={view.currentPlayer === 'N'}
|
||||||
|
style="left:372px; top:74px; background:{COLORS.N}">NORTH</div>
|
||||||
|
<div class="plabel" class:on={view.currentPlayer === 'S'}
|
||||||
|
style="left:372px; top:670px; background:{COLORS.S}">SOUTH</div>
|
||||||
|
<div class="plabel vert" class:on={view.currentPlayer === 'E'}
|
||||||
|
style="left:670px; top:372px; background:{COLORS.E}">EAST</div>
|
||||||
|
<div class="plabel vert" class:on={view.currentPlayer === 'W'}
|
||||||
|
style="left:74px; top:372px; background:{COLORS.W}">WEST</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.compass { position: relative; width: 744px; height: 744px; flex: none; }
|
||||||
|
.slot { position: absolute; transform: translate(-50%, -50%); }
|
||||||
|
.plabel {
|
||||||
|
position: absolute; transform: translate(-50%, -50%);
|
||||||
|
color: #fff; font-size: 12px; font-weight: 700; letter-spacing: 0.09em;
|
||||||
|
padding: 6px 12px; border-radius: 7px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plabel.vert { writing-mode: vertical-rl; }
|
||||||
|
.plabel.on { box-shadow: 0 0 14px currentColor; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { gameStore } from './stores/game.svelte';
|
||||||
|
import { toCoordinate } from '../engine/notation';
|
||||||
|
import { PLAYERS } from '../engine/boards';
|
||||||
|
import type { Player } from '../engine/types';
|
||||||
|
|
||||||
|
const COLORS: Record<Player, string> = {
|
||||||
|
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
|
||||||
|
};
|
||||||
|
const NAME: Record<Player, string> = { N: 'North', S: 'South', E: 'East', W: 'West' };
|
||||||
|
|
||||||
|
let view = $derived(gameStore.view);
|
||||||
|
|
||||||
|
/** Move log grouped into rounds of four (N,S,E,W). */
|
||||||
|
let rounds = $derived.by(() => {
|
||||||
|
const out: string[][] = [];
|
||||||
|
view.history.forEach((entry, i) => {
|
||||||
|
const r = Math.floor(i / 4);
|
||||||
|
(out[r] ??= [])[i % 4] = toCoordinate(entry);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let statusText = $derived.by(() => {
|
||||||
|
const s = view.status;
|
||||||
|
if (s.state === 'playing') return `${NAME[view.currentPlayer]} to move`;
|
||||||
|
if (s.state === 'checkmate') {
|
||||||
|
const winners = PLAYERS.filter((p) => s.result?.[p] === 'win').map((p) => NAME[p]);
|
||||||
|
return `Checkmate — ${NAME[view.currentPlayer]} loses; ${winners.join(' & ')} win`;
|
||||||
|
}
|
||||||
|
if (s.state === 'stalemate') return 'Stalemate — all draw';
|
||||||
|
return `Draw (${s.reason})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
function onFile(e: Event): void {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) gameStore.load(file).catch((err) => alert(String(err)));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="panel">
|
||||||
|
<section class="card">
|
||||||
|
<div class="turn">
|
||||||
|
<span class="dot" style="background:{COLORS[view.currentPlayer]}"></span>
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
<div class="sub">Ghosts on board: {view.ghosts.length} · Checks: {view.status.checks.length}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Move log</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
{#each PLAYERS as p}<th style="color:{COLORS[p]}">{NAME[p]}</th>{/each}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rounds as round, r (r)}
|
||||||
|
<tr>
|
||||||
|
{#each [0, 1, 2, 3] as c}<td>{round[c] ?? ''}</td>{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Legend</h2>
|
||||||
|
<div class="legend">
|
||||||
|
<div><span class="ring play"></span> Playable — legal on both boards</div>
|
||||||
|
<div><span class="ring local"></span> Legal on that board only</div>
|
||||||
|
<div><span class="ring ghost"></span> Ghost — twin captured, frozen</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card controls">
|
||||||
|
<button onclick={() => gameStore.newGame()}>New game</button>
|
||||||
|
<button onclick={() => gameStore.undo()} disabled={view.ply === 0}>Undo</button>
|
||||||
|
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) - 1)}
|
||||||
|
disabled={view.ply === 0}>◀ Prev</button>
|
||||||
|
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) + 1)}
|
||||||
|
disabled={!gameStore.isScrubbing}>Next ▶</button>
|
||||||
|
<button onclick={() => gameStore.scrubTo(null)} disabled={!gameStore.isScrubbing}>● Live</button>
|
||||||
|
<button onclick={() => gameStore.declareDraw()}>Declare draw</button>
|
||||||
|
<button onclick={() => gameStore.save()}>Save</button>
|
||||||
|
<button onclick={() => fileInput.click()}>Load</button>
|
||||||
|
<input type="file" accept="application/json" bind:this={fileInput}
|
||||||
|
onchange={onFile} style="display:none" />
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel { width: 290px; display: flex; flex-direction: column; gap: 13px; }
|
||||||
|
.card {
|
||||||
|
background: #1d2027; border: 1px solid #333845;
|
||||||
|
border-radius: 9px; padding: 13px 15px;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 8px; font-size: 11px; letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase; color: #9aa0aa;
|
||||||
|
}
|
||||||
|
.turn { display: flex; align-items: center; gap: 9px; font-weight: 600; }
|
||||||
|
.dot { width: 13px; height: 13px; border-radius: 50%; }
|
||||||
|
.sub { font-size: 12px; color: #9aa0aa; margin-top: 6px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
th, td { padding: 3px 5px; text-align: left; }
|
||||||
|
th { font-size: 10px; text-transform: uppercase; }
|
||||||
|
td { font-family: ui-monospace, Menlo, monospace; color: #cdd2da; }
|
||||||
|
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12px; }
|
||||||
|
.legend div { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ring { width: 14px; height: 14px; border-radius: 50%; flex: none; }
|
||||||
|
.ring.play { background: #46c24f; }
|
||||||
|
.ring.local { border: 2px dashed #9aa0aa; }
|
||||||
|
.ring.ghost { border: 2px dashed #888; }
|
||||||
|
.controls { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.controls button {
|
||||||
|
background: #262b34; color: #e6e8ec; border: 1px solid #333845;
|
||||||
|
border-radius: 5px; padding: 5px 9px; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.controls button:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { gameStore } from './stores/game.svelte';
|
||||||
|
import type { PromotionPiece } from '../engine/types';
|
||||||
|
|
||||||
|
const PIECES: { code: PromotionPiece; glyph: string }[] = [
|
||||||
|
{ code: 'q', glyph: '♛' }, { code: 'r', glyph: '♜' },
|
||||||
|
{ code: 'b', glyph: '♝' }, { code: 'n', glyph: '♞' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let pending = $derived(gameStore.pendingPromotion);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pending}
|
||||||
|
<div class="backdrop" onclick={() => gameStore.cancelPromotion()}
|
||||||
|
role="presentation">
|
||||||
|
<div class="dialog" onclick={(e) => e.stopPropagation()} role="presentation">
|
||||||
|
<h3>Promote pawn ({pending.from}→{pending.to})</h3>
|
||||||
|
<div class="row">
|
||||||
|
{#each PIECES as p}
|
||||||
|
<button onclick={() => gameStore.choosePromotion(p.code)}>{p.glyph}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 50;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
background: #1d2027; border: 1px solid #333845;
|
||||||
|
border-radius: 10px; padding: 18px 22px;
|
||||||
|
}
|
||||||
|
.dialog h3 { margin: 0 0 12px; font-size: 14px; }
|
||||||
|
.row { display: flex; gap: 10px; }
|
||||||
|
.row button {
|
||||||
|
font-size: 38px; line-height: 1; width: 60px; height: 60px;
|
||||||
|
background: #262b34; color: #e6e8ec;
|
||||||
|
border: 1px solid #333845; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.row button:hover { border-color: #46c24f; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { DuplicateGame } from '../../engine/game';
|
||||||
|
import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '../../engine/legality';
|
||||||
|
import { ghosts } from '../../engine/ghosts';
|
||||||
|
import { evaluateStatus } from '../../engine/endgame';
|
||||||
|
import { serialize, deserialize } from '../../engine/notation';
|
||||||
|
import { PLAYER_BOARDS } from '../../engine/boards';
|
||||||
|
import type {
|
||||||
|
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
|
||||||
|
} from '../../engine/types';
|
||||||
|
|
||||||
|
/** A plain, reactivity-friendly snapshot of everything the UI renders. */
|
||||||
|
export interface GameView {
|
||||||
|
/** Piece-placement FEN field per board. */
|
||||||
|
fen: Record<BoardId, string>;
|
||||||
|
currentPlayer: Player;
|
||||||
|
ply: number;
|
||||||
|
ghosts: GhostMarker[];
|
||||||
|
status: GameStatus;
|
||||||
|
history: HistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildView(game: DuplicateGame): GameView {
|
||||||
|
return {
|
||||||
|
fen: {
|
||||||
|
NW: game.boards.NW.fen(),
|
||||||
|
NE: game.boards.NE.fen(),
|
||||||
|
SW: game.boards.SW.fen(),
|
||||||
|
SE: game.boards.SE.fen(),
|
||||||
|
},
|
||||||
|
currentPlayer: game.currentPlayer,
|
||||||
|
ply: game.ply,
|
||||||
|
ghosts: ghosts(game),
|
||||||
|
status: evaluateStatus(game),
|
||||||
|
history: [...game.history],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameStore {
|
||||||
|
/** The authoritative live game — deliberately NOT a $state proxy. */
|
||||||
|
#game = new DuplicateGame();
|
||||||
|
|
||||||
|
/** Snapshot the UI renders. While scrubbing it reflects a past ply. */
|
||||||
|
view = $state<GameView>(buildView(this.#game));
|
||||||
|
/** The grabbed square, or null. */
|
||||||
|
selected = $state<Square | null>(null);
|
||||||
|
/** Triple-highlight for the grabbed piece, or null. */
|
||||||
|
highlight = $state<SelectionHighlight | null>(null);
|
||||||
|
/** A pawn move awaiting a promotion choice, or null. */
|
||||||
|
pendingPromotion = $state<{ from: Square; to: Square } | null>(null);
|
||||||
|
/** Ply currently being viewed; null means the live position. */
|
||||||
|
scrubPly = $state<number | null>(null);
|
||||||
|
|
||||||
|
get isScrubbing(): boolean {
|
||||||
|
return this.scrubPly !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which boards belong to the player to move (for the turn glow). */
|
||||||
|
get activeBoards(): [BoardId, BoardId] {
|
||||||
|
return PLAYER_BOARDS[this.#game.currentPlayer];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grab a piece: must be the current player's turn and a live (non-scrub) view. */
|
||||||
|
select(square: Square): void {
|
||||||
|
if (this.isScrubbing) return;
|
||||||
|
if (this.selected === square) { this.clearSelection(); return; }
|
||||||
|
this.selected = square;
|
||||||
|
this.highlight = selectionHighlight(this.#game, square);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
this.selected = null;
|
||||||
|
this.highlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempt to play the grabbed piece to `to`. Opens the promotion dialog if needed. */
|
||||||
|
commitTo(to: Square): void {
|
||||||
|
const from = this.selected;
|
||||||
|
if (from === null || this.highlight === null) return;
|
||||||
|
if (!this.highlight.playable.includes(to)) return; // not a synchronized-legal square
|
||||||
|
|
||||||
|
const moves = legalSyncedMoves(this.#game).filter((m) => m.from === from && m.to === to);
|
||||||
|
if (moves.length === 0) return;
|
||||||
|
if (moves.some((m) => m.promotion)) {
|
||||||
|
this.pendingPromotion = { from, to };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#apply(moves[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finish a promotion started by commitTo. */
|
||||||
|
choosePromotion(piece: SyncMove['promotion']): void {
|
||||||
|
if (this.pendingPromotion === null) return;
|
||||||
|
this.#apply({ ...this.pendingPromotion, promotion: piece });
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPromotion(): void {
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#apply(move: SyncMove): void {
|
||||||
|
this.#game.applyMove(move);
|
||||||
|
this.clearSelection();
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(): void {
|
||||||
|
this.#game.undo();
|
||||||
|
this.clearSelection();
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
|
||||||
|
newGame(): void {
|
||||||
|
this.#game = new DuplicateGame();
|
||||||
|
this.clearSelection();
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrub the move history; null returns to the live position. */
|
||||||
|
scrubTo(ply: number | null): void {
|
||||||
|
this.clearSelection();
|
||||||
|
if (ply === null || ply >= this.#game.ply) {
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scrubPly = ply;
|
||||||
|
this.view = buildView(new DuplicateGame(this.#game.history.slice(0, ply)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually declare a draw (provisional: insufficient material is not auto-detected). */
|
||||||
|
declareDraw(): void {
|
||||||
|
this.view = {
|
||||||
|
...this.view,
|
||||||
|
status: { state: 'draw', reason: 'manual', checks: [],
|
||||||
|
result: { N: 'draw', S: 'draw', E: 'draw', W: 'draw' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
const blob = new Blob([serialize(this.#game.history)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `duplicate-chess-${Date.now()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(file: File): Promise<void> {
|
||||||
|
const history = deserialize(await file.text());
|
||||||
|
this.#game = new DuplicateGame(history);
|
||||||
|
this.clearSelection();
|
||||||
|
this.pendingPromotion = null;
|
||||||
|
this.scrubPly = null;
|
||||||
|
this.view = buildView(this.#game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameStore = new GameStore();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { mount } from 'svelte'
|
||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app')!,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["svelte", "vite/client"],
|
||||||
|
"noEmit": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"moduleDetection": "force"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user