Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af38024fa | ||
|
|
4777a3abd5 | ||
|
|
46f64d37b6 | ||
|
|
7ca6cb8458 | ||
|
|
57068b61cd | ||
|
|
d9924a95b9 | ||
|
|
d09b6220ee | ||
|
|
c61b9832dd | ||
|
|
b7c7705266 | ||
|
|
25d9241b51 | ||
|
|
9089f4a257 | ||
|
|
92e1f58320 | ||
|
|
dc08eefce6 | ||
|
|
bc40a737a8 | ||
|
|
dd84dff055 | ||
|
|
b47e2588e9 | ||
|
|
1998e87309 | ||
|
|
9ac2fe47cf |
@@ -1,32 +1,53 @@
|
|||||||
---
|
---
|
||||||
name: replace-production-database
|
name: replace-production-database
|
||||||
description: Replace the populated SHD.build production database with a local full bootstrap bundle after verified backups. Use only when the user explicitly authorizes replacement of all production data.
|
description:
|
||||||
|
Replace the populated SHD.build production database with a local full
|
||||||
|
bootstrap bundle after verified backups. Use only when the user explicitly
|
||||||
|
authorizes replacement of all production data.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Replace Production Database
|
# Replace Production Database
|
||||||
|
|
||||||
This procedure overwrites production users, sessions, builds, guides, audits, proposals, settings, and source data. Obtain explicit approval for a full replacement. Use the ISAC import flow when the request only concerns canonical game data.
|
This procedure overwrites production users, sessions, builds, guides, audits,
|
||||||
|
proposals, settings, and source data. Obtain explicit approval for a full
|
||||||
|
replacement. Use the ISAC import flow when the request only concerns canonical
|
||||||
|
game data.
|
||||||
|
|
||||||
## Export
|
## Export
|
||||||
|
|
||||||
1. Stop only this repository's local development server. The embedded database must be closed during export.
|
1. Stop only this repository's local development server. The embedded database
|
||||||
2. Run `npm run production:bootstrap:export`. Record the generated bundle beneath `data/backups/`; it includes private account data and uploaded assets.
|
must be closed during export.
|
||||||
3. Transfer the bundle to `f302` over SSH. Keep it outside Git and confirm the remote copy is nonempty.
|
2. Run `npm run production:bootstrap:export`. Record the generated bundle
|
||||||
|
beneath `data/backups/`; it includes private account data and uploaded
|
||||||
|
assets.
|
||||||
|
3. Transfer the bundle to `f302` over SSH. Keep it outside Git and confirm the
|
||||||
|
remote copy is nonempty.
|
||||||
|
|
||||||
## Replace
|
## Replace
|
||||||
|
|
||||||
1. Confirm the current production deployment is healthy and no Gitea production deployment is running.
|
1. Confirm the current production deployment is healthy and no Gitea production
|
||||||
2. On `f302`, stop only the production `app` and `scheduler` services from `/opt/shd-build-production`.
|
deployment is running.
|
||||||
3. Run `node scripts/db-backup.mjs` through the production Compose service. Record and retain the JSON backup path before any destructive work.
|
2. On `f302`, stop only the production `app` and `scheduler` services from
|
||||||
4. Reset the production database file and its embedded-database support files. Keep the persistent volume and existing upload directories; the import writes the local avatar and managed-image files without deleting retained files.
|
`/opt/shd-build-production`.
|
||||||
5. Run `node scripts/db-migrate.mjs` through the production Compose service to create the current empty schema.
|
3. Run `node scripts/db-backup.mjs` through the production Compose service.
|
||||||
6. Run `environment-bootstrap-import.mjs` with `ALLOW_PRODUCTION_BOOTSTRAP=true`, the copied bundle mounted read-only, `--target production`, and `--confirm-empty-target-replace`.
|
Record and retain the JSON backup path before any destructive work.
|
||||||
7. Start `app` and `scheduler`, wait for app health, then remove the remote transfer bundle.
|
4. Reset the production database file and its embedded-database support files.
|
||||||
|
Keep the persistent volume and existing upload directories; the import writes
|
||||||
|
the local avatar and managed-image files without deleting retained files.
|
||||||
|
5. Run `node scripts/db-migrate.mjs` through the production Compose service to
|
||||||
|
create the current empty schema.
|
||||||
|
6. Run `/opt/shd-build-production/bootstrap-production.sh <bundle-path>`. The
|
||||||
|
script stages the bundle in the private data volume, assigns it to the
|
||||||
|
runtime UID, runs `environment-bootstrap-import.mjs` with the required
|
||||||
|
confirmation, removes the staged copy, and restarts both services.
|
||||||
|
7. Wait for app health, then remove the remote transfer bundle.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
1. Confirm the importer reports the expected row, avatar, and managed-image counts.
|
1. Confirm the importer reports the expected row, avatar, and managed-image
|
||||||
2. Confirm `https://shd.build/api/health` returns `ready: true` with an `ok` database check.
|
counts.
|
||||||
|
2. Confirm `https://shd.build/api/health` returns `ready: true` with an `ok`
|
||||||
|
database check.
|
||||||
3. Confirm the production app container is healthy and its scheduler is running.
|
3. Confirm the production app container is healthy and its scheduler is running.
|
||||||
4. Restart the local development server and verify it accepts an HTTP request.
|
4. Restart the local development server and verify it accepts an HTTP request.
|
||||||
|
|
||||||
@@ -35,4 +56,5 @@ This procedure overwrites production users, sessions, builds, guides, audits, pr
|
|||||||
- Never replace a populated production database without explicit user approval.
|
- Never replace a populated production database without explicit user approval.
|
||||||
- Never delete the production Compose volume.
|
- Never delete the production Compose volume.
|
||||||
- Do not replace the database while the app or scheduler can write to it.
|
- Do not replace the database while the app or scheduler can write to it.
|
||||||
- Keep the verified pre-replacement backup. It is the recovery source if the import or restart fails.
|
- Keep the verified pre-replacement backup. It is the recovery source if the
|
||||||
|
import or restart fails.
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
|
||||||
#/ public-key encryption for .env files /
|
|
||||||
#/ [how it works](https://dotenvx.com/encryption) /
|
|
||||||
#/----------------------------------------------------------/
|
|
||||||
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
|
|
||||||
|
|
||||||
# .env.development
|
|
||||||
# Server-only OpenAI access for the permission-gated ISAC mechanic authoring
|
|
||||||
# assistant. Use a project-scoped, Responses-only restricted key.
|
|
||||||
OPENAI_MECHANIC_API_KEY=encrypted:BGRKl6pMDudJdr4yvye51GWuIsyjPfgarjsyxFnDs/HdgcRPdttk7nNz/40TEdfHidyaWHvag6HkM46zG/37VEhFpZ5N3iPPJmKV1a43NaAZNUeOP0dy6ct8UmOd6gEjDK9U6wzrjczRijgAffZ3GDyKyxkeDrn+8PHjng/0sXL78tpPr4p040cTEfSyet/XRsIUqeiV/6euS60afbnyzfOMUJ5liPdXK4VoJh6iLCsVrpmoNZ+rB5FMd65vpdSXOy6MKoG25aGq/1gtC5JOBiknrSE4vYGnuMriczklJviEBma+4rKMTSLTBOnYnHzpsuDC7+Tcft9VcluvNgcy0McGDExm
|
|
||||||
OPENAI_MECHANIC_MODEL=encrypted:BCCIuv04UwV44nzKVT3X7PpzbUJofJXPj/X2EYqErcSxedHh7IDAszLKgrBL+uzAT3xLOYS8lhOYfaFl/GaI12oOWAiZV2pijxRjpOE5Zv6X9wFJ7c+su44IRa7GIXF8wwnfWnSwSAg1S6kdS98=
|
|
||||||
OPENAI_MECHANIC_REASONING_EFFORT=encrypted:BDOTlCFCvs30g+to1IJD9aFhfPqrVPTccpRJKiUyywAm0ojSihpUL12guVnvjLG9NR2MrzkV1icqviDSR4yqsPC6Zz+Llf3KId4IBMXCa+OIUd+KOCM1AN3adoU9ZcBOIxuKZf6XTg==
|
|
||||||
|
|
||||||
TDR_DATABASE_PATH=encrypted:BHzbwoU8Nc6yeHEwxLKFt08YVpz0+IRDBaRRu8mZlKtoi3R7qDOriv1+mIwLxkuZg+n8k7hGf9XhEipvfhbhx/N2sMcvsnVcrZfLIWItcNfrq9O8XmxE5oQpIQiYz/Osm9vclOeu06C6MwykowCtksIQD2xtCLLHInFPa8pJ0M0=
|
|
||||||
DISCORD_CLIENT_ID=encrypted:BAUbUqppB9HUQocELaV74nB1Ot6blLFNZ+640X8QLX5VWDzhABsw8JluD34L0ooYZve794HZH8ZFSn/zcvOsgSbGOdte9RQAa0YZpSz3Ih+TAmCvNUWg59A7iE2O1jCB5IEsCrRGbg4UslnP+I9ZHgnjRKo=
|
|
||||||
DISCORD_CLIENT_SECRET=encrypted:BA6jiQ2ws/e+y08nRwx2hyH2IlKBOjYVi+kk4gcYH6YyEvGw7HwIMRJ/n5eQx/IQB3KNDnklwL7x9XNGaq2Ht2UOHfrV+HFjPrTy/Kwt6retmMkfrA3PYJ7PdI+SlxE+hzvhYLvBJz6q1IcpRH09gz/x4jYeJPqD/LvPSKU2RlQ9
|
|
||||||
DISCORD_REDIRECT_URI=encrypted:BJl92lm9C5zv7guW8O5faHE5t53c5orIMLte3TOOaMFMGcWlybqNuQEf6SmLfgwQ/AoAviJZDxOQRhRaKI5OR4s1r54Fbe2oHQBSlgcCyIKmACukOY9dg9nlpx/EkkvIY77hekCJhh/RfD3P5Os2OfSok7uiZxEGcagkA8H3/2KbpRUZSjWVEqNZjyM=
|
|
||||||
|
|
||||||
# Passwordless authentication
|
|
||||||
APP_ORIGIN=encrypted:BBjQBgg8fXVjAjSrfzhAmuDuVRpvOlrKZTGqInncSwhJENhUs1EN/mBOlD769tAWEiNQ19mdDhIN1Dsiuu9Ps2VksDNdXjVNGwMYj3TRlX6v9wmzgODoCm6fvlseNJUQqXWSqEGgnrBzg90eg8bRMOzQMBByrA==
|
|
||||||
PASSKEY_RP_ID=encrypted:BM8t3iboOCY37Mg3UfYG1b17shBtNvM1QhkPAQPEq6gHIZz5nWeLVIqkZiLdMP81fmyEjmpD4cpnHNffy81xMyqSvzYZfIQ1UhNAM5vJMc6aDB535fRNVRYyf4+zMbiRJtyc+010RN3kCw==
|
|
||||||
|
|
||||||
# Shared SMTP account for magic-link and email-verification delivery.
|
|
||||||
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
|
||||||
# SMTP_SECURE=false.
|
|
||||||
SMTP_HOST=encrypted:BE8dBQOkKsQuDh9SDx3CTyUOzQ8uuoDMlw4bCxQBs7apyg6P5FqZJIvEgnzCRHCo3dC7skgi/KDUDZI1stLX5A9tvlLvasCf2vGiMnn7NgG0e1nAYD1PiAnbQDmqfUcYBMLIWJibqIF96Yv1icnEcfNwkw==
|
|
||||||
SMTP_PORT=encrypted:BKt0uerEl2/lqwev72twYoeI0lUZdXgONWB+Xm14ftBG/6vY8E2UiH+/+6Zrf2b8Y1D11PzsZqEGaCYCJ8v1PyDnAQrE0pC4WjiYCl9kCkqsFvtZQcWys3rm0Nu6zrV8s0pYPQ==
|
|
||||||
SMTP_SECURE=encrypted:BFj5YB/gxpOwKdCrgUsydheEDV5aYAvtNMtLFSslYgsEkPLWe6nA1deQDXjVEwZxLC6iAgOgzxErRbem8nK5BNFfwpMuwwily+NQWZgPYMu7gD+BPmT9BMNvJkJvCwHEPj6Q6AT1
|
|
||||||
SMTP_USER=encrypted:BC7+7c4vy1aS9mkuOaYZbxOdgZunja8UCz3P+ap+ZMK6qqV5KXvp+DPVvtJvVyCZejisS15LFsL+1VCeiD8hYyFKBrE2YJ+kx1hDwNezVMr1Dza548b9dXeMLh7Is6MjN5XBLLUjk2HcqgeXZ8WOmx01Dg==
|
|
||||||
SMTP_PASSWORD=encrypted:BOgcw3hrxXlMuQ92RNr+3M+myxlpd8lAtieebwXMsRQKiqLAKSDz3x0VXetvC2foMJ/KIEzTqM5scqkUA7dnm1/tQe0N3dG3ZZFzYIMz90Q0I5/XPWzli6cGtexoujswpkfnAa4EeGlvNEAhOYQrciU=
|
|
||||||
SMTP_FROM=encrypted:BGrMEhbploGfX29mvT+8XjgxvXK/QNjzr4CbAywkDyJmZ2xlscDbLC3+UoRSanGMdbVHvbDbYx6wG8M3MQ2QeIknf2AB75LpeeIVquSWa+u6OQBHmiRgEXWRuQJ7leTahd+B+E87gCpVtC3yFcHUtW3STyV1ZyidQJVwsI4HmQ==
|
|
||||||
REMOVE_BG_API_KEY=encrypted:BFeraOe9tiw3wCooTZ2ZE0qG9dDwqlpzq3HpEA0J+2LGckZSUxYP4f1Po1LNqWWihX+TzACONzHDhgjSaNq9XTMdjitYw7bEFehQQxsVi5iiJ/C6ZsN9M6vNIRP0FH+SfcYvFI41KJbSNOBFIX4zm5Y8hxG0aFJKkA==
|
|
||||||
+13
-3
@@ -1,3 +1,6 @@
|
|||||||
|
# Infisical secret inventory. This file contains names and safe defaults only;
|
||||||
|
# the application does not load it. Store values under /app unless noted.
|
||||||
|
|
||||||
# Server-only OpenAI access for the permission-gated ISAC mechanic and
|
# Server-only OpenAI access for the permission-gated ISAC mechanic and
|
||||||
# calculation authoring assistants. Use a project-scoped, Responses-only restricted key.
|
# calculation authoring assistants. Use a project-scoped, Responses-only restricted key.
|
||||||
OPENAI_MECHANIC_API_KEY=
|
OPENAI_MECHANIC_API_KEY=
|
||||||
@@ -13,7 +16,8 @@ TDR_DATABASE_PATH=./data/shd-build-development.db
|
|||||||
# Persistent directory for normalized user-uploaded avatars.
|
# Persistent directory for normalized user-uploaded avatars.
|
||||||
TDR_AVATAR_STORAGE_PATH=./data/avatars
|
TDR_AVATAR_STORAGE_PATH=./data/avatars
|
||||||
|
|
||||||
# Persistent directory for uploaded talent, brand, protocol, and skill-chip icons.
|
# Persistent directory for game-data icons and managed library images, including
|
||||||
|
# weapons, gear, mods, talents, brands, protocols, Skill Chips, and Specializations.
|
||||||
TDR_GAME_ICON_STORAGE_PATH=./data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH=./data/game-icons
|
||||||
|
|
||||||
# Optional fixed credentials used only when initializing or explicitly resetting
|
# Optional fixed credentials used only when initializing or explicitly resetting
|
||||||
@@ -25,6 +29,7 @@ DEFAULT_USER_PASSWORD=
|
|||||||
DISCORD_CLIENT_ID=
|
DISCORD_CLIENT_ID=
|
||||||
DISCORD_CLIENT_SECRET=
|
DISCORD_CLIENT_SECRET=
|
||||||
# Proxied development: https://dev.shd.build/auth/discord/callback
|
# Proxied development: https://dev.shd.build/auth/discord/callback
|
||||||
|
# Staging: https://stage.shd.build/auth/discord/callback
|
||||||
# Production: https://shd.build/auth/discord/callback
|
# Production: https://shd.build/auth/discord/callback
|
||||||
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
|
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
|
||||||
|
|
||||||
@@ -45,9 +50,14 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=SHD.build <no-reply@shd.build>
|
SMTP_FROM=SHD.build <no-reply@shd.build>
|
||||||
|
|
||||||
# Long random bearer token used only by the internal container scheduler.
|
# Cloudflare Turnstile challenge keys for anonymous Guide activity.
|
||||||
SCHEDULER_TOKEN=
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
REMOVE_BG_API_KEY=
|
REMOVE_BG_API_KEY=
|
||||||
REMOVE_BG_LOW_CREDIT_THRESHOLD=5
|
REMOVE_BG_LOW_CREDIT_THRESHOLD=5
|
||||||
REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10
|
REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10
|
||||||
REMOVE_BG_CREDIT_CHECK_HOURS=6
|
REMOVE_BG_CREDIT_CHECK_HOURS=6
|
||||||
|
|
||||||
|
# /shared/scheduler
|
||||||
|
SCHEDULER_TOKEN=
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
|
||||||
#/ public-key encryption for .env files /
|
|
||||||
#/ [how it works](https://dotenvx.com/encryption) /
|
|
||||||
#/----------------------------------------------------------/
|
|
||||||
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
|
|
||||||
|
|
||||||
# .env.production
|
|
||||||
# Server-only OpenAI access for the permission-gated ISAC mechanic authoring
|
|
||||||
# assistant. Use a project-scoped, Responses-only restricted key.
|
|
||||||
OPENAI_MECHANIC_API_KEY=encrypted:BIQtxqHheOrBdpeabt1p2Y1+TCHQDIxp5DHpQMUsS5N8aqukt/IELJSKWo9LXaFXbExHFB/X7Z2WvuWSyLiSA1QyZeUnUwMxnaJiYCpPlM3gTGvvlw/6U9uPxPBO7dLwchTMtqX9Sfx+GSWFzojfPOsVNaUVBRjBy5VTSOLwxMw0LMI+oYGVDEbjR3k/74sHlGXQazN0mThk8NClPJg6BSkKuKKFQLBjc4RyWUm94SE9KZwqiZVkfQaMmn+a9zLvbfd4yZaG4Ae/sneIU1T+cYjqdYAT05kBf8DikJkaezC2dRvcZ8I7jsp08pt+xHj3UC4XgbDLK2IVyLO4C8ELP5g7vQVy
|
|
||||||
OPENAI_MECHANIC_MODEL=encrypted:BLZffo6IeKN95zCneOq+FrdTjcjPF+vRkagV7pWAVgdusAK0OWR6B2bFqbqBuRVmos32uEWS4aP2GLrGpOr7gPlGDQTPu2EBAv4eT1OD5ewMC3lJFuqrXnr6j12bJOG8BUM3l+pMxwFBAXrDUew=
|
|
||||||
OPENAI_MECHANIC_REASONING_EFFORT=encrypted:BOrPunzMR1atHilHR6MEXaUDEMyPI7gkhyF4bSO1byh2DqGGEyKcGxLf6A2vIOcFM38/k9g9QCxuth54B4Zs5mHE5MUmvj6g8nwDSfO/ae5lAmYLSCqj4MLaDQ2LcJ+vYDrJ8aLpfA==
|
|
||||||
|
|
||||||
TDR_DATABASE_PATH=encrypted:BL1wSNLW9BwOyzX89x73UjkxbJIUsOgOGkvLUfBRCqAAL2T4OVccEzh9aTWmLNaEICj6L795Tyd3Up+OZQ34NxqASKKk3o7piwQj8hiRU6W89fLVlSSZxdgLGq2S5YUdm3a/h9Loa1H4iDkg0TxO+MEQ2Qu53GD8zxOYWSQuBQ==
|
|
||||||
DISCORD_CLIENT_ID=encrypted:BB7n8VWz0GAV16Fkm7eJIfxI0a/rgHP1YakJ9XeiAvflWdYmndF5dvboOli+yLtuGCe+VqKUnzyLeQ7tk2YPQmMlkDXe75bhRkrLyPPvU0eg1DzhZ9ptU/qmmJZfrCQtT5o+/R61umVmfGkDB8nhpPkzqE8=
|
|
||||||
DISCORD_CLIENT_SECRET=encrypted:BJbCD14+3ZJ9T9+y90Uh3ZgRn4zyOqA0ckN/H+A9JfyWGgM2R5PBP2bmOPPGwRZk1Lg2GOWsowox4nrZEmINuUYirLRaszm9sqVejYXWsJPsTiXbukW+4rgtDpW7ifVkajbu/QGb8NqQR0tPUzJMYM0U5at5NM7TrqKWuu+4cLHf
|
|
||||||
DISCORD_REDIRECT_URI=encrypted:BKRD6GzZg6Qkt/ydrqkta4pUNlXNQzI4Kdq1ymn6qJH4O7NYVGE4J7MNO5biVsWPQuNU2ZG5qFatxzbbZMWT50x0o+A9wqmQ5XXt1kln8puHZsVOvOqSAI1z7mvWA+nuewv3vRDu0fchSsgSgFRjL1xwpp5tNac8DnvNaw0K/NlWHDdgZFIajQ==
|
|
||||||
|
|
||||||
# Passwordless authentication
|
|
||||||
APP_ORIGIN=encrypted:BCDOpIDmpg3cipvTGTD/2XFxazXZBJLLf0/Y/sUDdRoKTEaF3m1dh7H3Ep9gyvRSLcbXxVnu3vPBowE5L7/pB6S3KRkjfdwvEImgUmvpfFfkLXM1/AiWO3M1drkfB+0lx5x5ktbtuNpQ22bckMraFzyx
|
|
||||||
PASSKEY_RP_ID=encrypted:BFyFsPV8oZCPkKROL0Lo8aDmKK+ay8jy5Z4HeYdkYvfF8e6eaaS29doIyXqEV0eeHEF/iFf5UspARhlf8To/lB1TE8KHXBiHAgfTVhur+tCN/TUzocH1jr1jnw77qykey5FXcoTKqD0+VQ==
|
|
||||||
|
|
||||||
# Shared SMTP account for magic-link and email-verification delivery.
|
|
||||||
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
|
|
||||||
# SMTP_SECURE=false.
|
|
||||||
SMTP_HOST=encrypted:BFenO3Hh63xJz6dcJrEmCM40VGjPV7Y+F0TVgRRWAGMeBbrYZddhoJi7hhHZXpIX6B3nrSvq7dWIL+0aPKPzyWMgBsj3AnlXx8qi19CejStRQBHTQPyGnlpAZJK4SyBv24RwpwUs19T/TcK8b1DCX3tHYg==
|
|
||||||
SMTP_PORT=encrypted:BBxjsH6/dJcBap/sCS97SUPjuhuYSrFR1mqDvzaifCP52n/8jZ0+EYdkK0FcsU8ZhgHAWZ6vRkBjxr3nM39YXLEyATzbNF/tFZg29WGOk3kOxRhgemry0+kK2/UmZ9KUBTn3dQ==
|
|
||||||
SMTP_SECURE=encrypted:BAnU6hfdVX2xiPtP183dGjofqmz50/lB4MBeuWoer4KjwOsQXh1b/jOrGACUY7IKD2ngi5IMNdAtcqIkxdQGEfXj9GvPnkOIMNSwFwKNafIsZpS137UGmZCW1XopXU5cID28iSY7
|
|
||||||
SMTP_USER=encrypted:BMREkzkhV9Aa4E22SskfpcTt3zbGSPwhvRqTmfXlclta6FZtQD7Oy8rDifhNaMf78V3hJkoaXPk9bcZ8zZn2NnG72NPbRXxukn6LiLePhb78z24g4UiiHON2MP7jIz4pvhPCSET08N/Km5kfiMLvp8otQA==
|
|
||||||
SMTP_PASSWORD=encrypted:BG39vFySsPvtm4AsuZZk5UbNaxHOOjxd8yffgsZebGNjxLY8aluuLf3/XMFquOz3bb59ZcyYSPv0Knp/N5c0vSHkl/ByC8WuQFTeLYH6BzpUADiVlGPE9Wg5Oa6dCOmianfRtHBUVrG1DC/BztBVYRg=
|
|
||||||
SMTP_FROM=encrypted:BMGyxQY0VdtmVfyiRl7NoO6dz8Q2pqvx8CLXDX/ZS9FsE98SUynl2MeOrOU8Vjz55V+KcLfFUooZoYT5Ke46YfRGMmE0ke2OaDZxFCBnD0rv6Mm9sRqXdyTn0vUxhjXmqLFkef32hN6VvBNN+xlppwwwbgYTg6pwtx6LKBQ1eg==
|
|
||||||
REMOVE_BG_API_KEY=encrypted:BLrlul9iafui2MD8NcqLYgwc2crtcC2HbXIX1QNDHy5sX2PMitvRCYOefEKzT6JmNXT2xvD0/uOKB7WTdKG1+qv7jVWl3IBK9wc2ftAtAWgESsUPy2mLKomXe8N04wz682om7EgwJ942294NWMWPLrWGYZCH1vOtog==
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
|
||||||
#/ public-key encryption for .env files /
|
|
||||||
#/ [how it works](https://dotenvx.com/encryption) /
|
|
||||||
#/----------------------------------------------------------/
|
|
||||||
DOTENV_PUBLIC_KEY_STAGING="020bab072314925eb9a9cbaf42282e3a0d5c4a8de3d88a51ed92b4b3c83d598f98"
|
|
||||||
|
|
||||||
# .env.staging
|
|
||||||
# Server-only OpenAI access for the permission-gated ISAC mechanic authoring
|
|
||||||
# assistant. Use a project-scoped, Responses-only restricted key.
|
|
||||||
OPENAI_MECHANIC_API_KEY=encrypted:BL4P5f594pMu3zF6onW128E+17uT7Vh7CwnpcAndHTNLAW3Vx3w99MawmLYZ17iiWgu6GF6ZzqavHpGgtTdYa1mU2zSwwfm5f0/gQP9lNM7KETdG8EIQfa167d3XixJu2EeW7Z17dTUEbEOOJ8xiI2HwsIZ/X37KEqYHZ0GJIzU0rXo4i6G17rHRs2OiByZL80ujQNsjU5lPHGnDikmAaSSczqtR0Q4/QmSd9EFDrRLIIGtDwqpCG8C4lV6JxRaU6SrfH+4GtUxSHyvITnsAAAzVVQ9yUmddmPkkfAdmAjz6XlrX5mdNnrjAJI9Z95RGTeCrwXCMI9ffulH84xxpXyXG+qez
|
|
||||||
OPENAI_MECHANIC_MODEL=encrypted:BLyNUZ2WPB+jMJOqLoV/xAbd31RsrXbGkV2EChn0vy8rh5yVoqbUX2aoqsTU6mv7oP1NT3CAsC/YkfiJFJ2FEiH9GT8Ddrs43QFMBvU2c3AUlZGZJeQDEIoqjWqmt7I6EYeM5gFiaR4o/i2NEHk=
|
|
||||||
OPENAI_MECHANIC_REASONING_EFFORT=encrypted:BHU/0x0Ez9jvwYPhmTCPh4ciVPYyVQTfjfCSb8zV2iNwH6c6LNkEs3omWXvA4wpUhNwub8z79/w4N2QGEnxOy/n8jFnhXqf+mI++niqmRXp8nuFXY0NUGF+THiZlssiKzIAyFXXeCQ==
|
|
||||||
|
|
||||||
# Staging is deployed at https://stage.shd.build.
|
|
||||||
TDR_DATABASE_PATH=encrypted:BAh41Twl5JvpTKOaxlfw2J8dMBqRy5ontNeIPV8XFqrzQBTdQ7EmLn4gv+OOal4pUGlRQ2onC8TFN8vD0Xr83n1AQRYe48ovlDIRGeWFJoCimNSXqymrT+avXhNwRrMEjIu2E8YW0W8JWDXGWB1c4G8epRLy49Plq6Gzd7P+/RM=
|
|
||||||
TDR_AVATAR_STORAGE_PATH=encrypted:BH3DJiNNFUPnK83cby/HVCk0DHoBh2sNrnV1Txoz2Bh5EMPFSVpXdZaAz6C/LkdD5a2mKcDHFZb21Wy/GVJc9N3XNCJQz7GtvWrPNMv4oDFDJjDyeWHFHhPZmee6h3izWVO9mQDQTioamt7r50swMtP2
|
|
||||||
TDR_GAME_ICON_STORAGE_PATH=encrypted:BKNWYaPZsXRaUr9gXAFfS5ULOqQPCBaO8fzsfpPMdh9L7dhMPgiIsqNzZlLUr5Ywl7JzKc9EFbdeuVYP2MFpeta0x/lwGnJFy+hjcBHlanXPUyLIYFEs0h2MZdwB4b24un9kZo6igsF478qWRfP2PLPCZ4eq
|
|
||||||
|
|
||||||
APP_ORIGIN=encrypted:BAN8bBXyHn0fLmn/HqlK3cXfess/XF0HGYRAKxh89hyV26VHETLKgARQogdzjaAU4IRoTB6Lxr6pXzhuVRtQhsn4SiloTQ+t9Ecg/B7IbJ60F2TlcM5/aMq9QodpDGV2jZKVT7MsDl7yQuOyMca8BDJE6YiYGA==
|
|
||||||
PASSKEY_RP_ID=encrypted:BMF+2RmswtWB5Z8eaebDFdK306JvBeu8fqPLK7DmfObq980Tc57a8t0fSOUt13k+f16CnvRc0EeHORq+R2vN0jQW3korDKptBa4LHRJr68u8ITXKqU+gkzIzGWLOdfmkMdXKPN4AGeEryw==
|
|
||||||
|
|
||||||
DISCORD_CLIENT_ID="encrypted:BE1JPS3brhFpQ7qvCqmAi9PoQxFKnLZGBe/IrZQ4atx6s0byUQ6RrRW4cRK+XG08FCikCbzW0BaDSjy3z/6I+0dNvCZEMcsfU7v/ZFlmrOpoPF+sxrQNHfbDHaYT0MnOSlvtpnPGAm/FoT4SVuS0XdTyY2M="
|
|
||||||
DISCORD_CLIENT_SECRET="encrypted:BDEjA2trC6RqGqDkwq2R8pGdD8xWqnhyFhkS9hxxUt4GJpTtTSUteU1zwHodGc+Xj7UUXAd5o3+MVc31N2yPyVLVHkdN3tkEb87nuUOdxHhtbBW7fPAszq/n5jMJfIfEI9/lvCB4pq/yVKxDkHJt+sWoZhdqUuLgtRFOE0IPZwyW"
|
|
||||||
DISCORD_REDIRECT_URI=encrypted:BNM4ROa+P+E06QttCNhxzIwzTKmZb7IJLaK8gIH1n07gadfrNfAzbvFWCOZHnj4mcTvRnWym6761KnkL4FIgGOcLTx8eoGKmFM6WRAgA1u5MAz2k0fhyuNxAU/h0J+VdGLdFa3WpkjEY1TjD1A+6dhDv62A7XF8wCJtZx5mTqzjPjRdalxA/3GwgKRQ=
|
|
||||||
|
|
||||||
SMTP_HOST="encrypted:BMjOo3Z/HyXK4c2fiVIqyud3bT83eBuD3uCEs97j2ymE0OAvoEqYaux8fMnSFull6kqvBLnfsxRR7BJaH6B15/Y8bhWmMIzPx1nfmkQTgbHY/esF+LSRZ18WeAIk7NOuvPRWacpY2CtadMZZ9hIe7AIhYg=="
|
|
||||||
SMTP_PORT="encrypted:BCaQ3+9VxAash3YSjHRQ/pt1RLy6xjVpzMA4MSDuyFlfnMjE20ywt5ZtnqRrmKzXQa2KELF0mm92v1VehREwUZReD+W8J3p9sSxpG+D4SjDRkXfVEAfXuYml9E+vqwirianL/g=="
|
|
||||||
SMTP_SECURE="encrypted:BAyezwpS+qNxZB0JmXJcQyiAS5Lua5XrURUnW1JUobBzTraqafs+2MpNJt3/LzVPayVv9nfuuQlUYfLJFnF6yRLQehqZobkaC6Hh+Q6AiWD3ZgkG+Sn6ON9c/Db+fltzt9D5lqbS"
|
|
||||||
SMTP_USER="encrypted:BHnngZLsV6cbONJ10YvT/46wYXA8zZQB4W50VodF/6E3MyRcY9GiUmW+vaCFsrq71KA8HMKQaJ1P+3dB5u/7kaWPzMW8UDUWkBtaBnr0CJLCxN3bff2SA2+iCcHvjgx0bcLJS83JhnKsdo6Fb42IkuEGgw=="
|
|
||||||
SMTP_PASSWORD="encrypted:BJs+zmP95i0SCm5h228DpILK5Q8BLX3wpsUx+5qEQgFakGV2b5h1x59q4TJcvX1wkTcbEHG+8ZYI1B2NJCgNgKFxRV4+uBnybIwP9rpHUQGL+kycYaM+hatvLzt9rA0eY+Gbm4AwhbiQ14rWxGqHnD4="
|
|
||||||
SMTP_FROM="encrypted:BCrtD05OXCA8xlrFHvRLN/w16m43/S4da2xYWfYsvPIWeDWN56KURl9J6jljUpi5KzPutOfut5azWyKMssNdxAZqlRYQefFBBbrLK+lZjqccGEw2mKwLAibllYqPhiL5hK3bW5fUjojYqAL6Kb1UvN5KtkGzjIe2YW1jsc9eCg=="
|
|
||||||
|
|
||||||
ORIGIN="encrypted:BMh6IEGoG+8iLK4a8vDpqduz3bGJ7ZI+X3oBnmmM0sbff3MaU/u3bdPuBtKcxxzMI8+Auc9TVlHOyDwM5pq40ny96CT/ZiMBQH+qFOjN797Vi7GO27eK58Z7iQuN99eQep1Pwxo84JruTUjhEuyYpVyqoQHl/ibN"
|
|
||||||
SCHEDULER_TOKEN="encrypted:BAUdHe+C2fE9hU+uzbpQM36hNC7ZY01smAcCuE8uUA8n2QhhVAD6z3Meb6k+IokarpxUrua7W1S7knQx7hdDgZfDPqWsbJW0GYP9Ym6VNe1chJ/F2ceaNxPAPp0jsZ55Vj22rQ1ljni5vD1oSBE2gTkrsqt95KS/qiaRMTRxEkQ0lmXZscmkXhmeNy0="
|
|
||||||
REMOVE_BG_API_KEY=encrypted:BFcytS+vbpSp2AhQ7nnRZvGbQCCuDCcp5D6irWwodBVnKB1zosiXapU3Nw6lTwHepl6t+j8hakxF3mKyJmB+6I5hZlzH/v6VUYUQnlYaPE43dYYYbGrseg4ZYK0W9O7SWlOyHHgIv7Ktkx3nqBIwa6PS50Bjoq+8SA==
|
|
||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: shd-build-production
|
group: shd-build-host-maintenance
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Validate application
|
- name: Validate application
|
||||||
run: npm run check
|
run: npm run check && npm test
|
||||||
|
|
||||||
- name: Build Node application
|
- name: Build Node application
|
||||||
run: npm run build:app
|
run: npm run build:app
|
||||||
@@ -33,30 +33,63 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install environment tooling
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Prepare production configuration
|
- name: Prepare production configuration
|
||||||
env:
|
env:
|
||||||
DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }}
|
INFISICAL_APP_CLIENT_ID:
|
||||||
SCHEDULER_TOKEN_PRODUCTION: ${{ secrets.SCHEDULER_TOKEN_PRODUCTION }}
|
${{ vars.INFISICAL_PRODUCTION_APP_CLIENT_ID }}
|
||||||
|
INFISICAL_APP_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_PRODUCTION_APP_CLIENT_SECRET }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_ID:
|
||||||
|
${{ vars.INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET }}
|
||||||
CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
|
||||||
CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
|
CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
BUILD_NUMBER: ${{ gitea.run_number }}
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
: "${DOTENV_PRIVATE_KEY_PRODUCTION:?DOTENV_PRIVATE_KEY_PRODUCTION is required}"
|
umask 077
|
||||||
: "${SCHEDULER_TOKEN_PRODUCTION:?SCHEDULER_TOKEN_PRODUCTION is required}"
|
: "${INFISICAL_APP_CLIENT_ID:?INFISICAL_PRODUCTION_APP_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_APP_CLIENT_SECRET:?INFISICAL_PRODUCTION_APP_CLIENT_SECRET is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_ID:?INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_SECRET:?INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET is required}"
|
||||||
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
||||||
production_port="${CONFIGURED_PRODUCTION_PORT:-3102}"
|
production_port="${CONFIGURED_PRODUCTION_PORT:-3102}"
|
||||||
|
case "$deployment_path" in
|
||||||
|
/opt/shd-build-*) ;;
|
||||||
|
*) echo "PRODUCTION_DEPLOY_PATH must be beneath /opt/shd-build-." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$production_port" in
|
||||||
|
''|*[!0-9]*) echo "PRODUCTION_PORT must be an integer." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$production_port" -lt 1 ] || [ "$production_port" -gt 65535 ]; then
|
||||||
|
echo "PRODUCTION_PORT must be from 1 through 65535." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
mkdir -p "$deployment_path"
|
mkdir -p "$deployment_path"
|
||||||
cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$deployment_path/"
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
DOTENV_PRIVATE_KEY_PRODUCTION="$DOTENV_PRIVATE_KEY_PRODUCTION" npx dotenvx decrypt -f .env.production --stdout > "$deployment_path/.env.production.runtime"
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
printf '\nSCHEDULER_TOKEN=%s\n' "$SCHEDULER_TOKEN_PRODUCTION" >> "$deployment_path/.env.production.runtime"
|
rm -rf "$candidate_path"
|
||||||
printf 'SHD_IMAGE=shd-build-production:%s\nSHD_PRODUCTION_PORT=%s\nSHD_BUILD_NUMBER=%s\n' "$COMMIT_SHA" "$production_port" "$BUILD_NUMBER" > "$deployment_path/.env.deploy"
|
mkdir -p "$candidate_path"
|
||||||
chmod 600 "$deployment_path/.env.production.runtime" "$deployment_path/.env.deploy"
|
cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$candidate_path/"
|
||||||
chmod 755 "$deployment_path/deploy-production.sh" "$deployment_path/bootstrap-production.sh" "$deployment_path/sync-production-isac-to-staging.sh"
|
write_credential() {
|
||||||
|
target="$1"
|
||||||
|
value="$2"
|
||||||
|
temporary_file=$(mktemp "${target%/*}/.infisical.XXXXXX")
|
||||||
|
printf '%s' "$value" > "$temporary_file"
|
||||||
|
chmod 600 "$temporary_file"
|
||||||
|
mv "$temporary_file" "$target"
|
||||||
|
}
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-id" "$INFISICAL_APP_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-secret" "$INFISICAL_APP_CLIENT_SECRET"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-id" "$INFISICAL_SCHEDULER_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-secret" "$INFISICAL_SCHEDULER_CLIENT_SECRET"
|
||||||
|
temporary_deploy_environment=$(mktemp "$candidate_path/.env.deploy.XXXXXX")
|
||||||
|
printf 'SHD_IMAGE=shd-build-production:%s-%s\nSHD_PRODUCTION_PORT=%s\nSHD_BUILD_NUMBER=%s\nSHD_COMMIT_SHA=%s\n' "$COMMIT_SHA" "$release_id" "$production_port" "$BUILD_NUMBER" "$COMMIT_SHA" > "$temporary_deploy_environment"
|
||||||
|
chmod 600 "$temporary_deploy_environment"
|
||||||
|
mv "$temporary_deploy_environment" "$candidate_path/.env.deploy"
|
||||||
|
chmod 755 "$candidate_path/deploy-production.sh" "$candidate_path/bootstrap-production.sh" "$candidate_path/sync-production-isac-to-staging.sh"
|
||||||
|
|
||||||
- name: Deploy production
|
- name: Deploy production
|
||||||
env:
|
env:
|
||||||
@@ -64,8 +97,12 @@ jobs:
|
|||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
|
||||||
"$deployment_path/deploy-production.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-production:$COMMIT_SHA"
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
|
"$candidate_path/deploy-production.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-production:$COMMIT_SHA-$release_id" "$candidate_path"
|
||||||
|
|
||||||
- name: Remove unused application images
|
- name: Remove unused application images
|
||||||
if: success()
|
if: success()
|
||||||
run: docker image prune --force --filter "label=org.opencontainers.image.title=shd-build"
|
run:
|
||||||
|
docker image prune --force --filter
|
||||||
|
"label=org.opencontainers.image.title=shd-build"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: shd-build-staging
|
group: shd-build-host-maintenance
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Validate application
|
- name: Validate application
|
||||||
run: npm run check
|
run: npm run check && npm test
|
||||||
|
|
||||||
- name: Build Node application
|
- name: Build Node application
|
||||||
run: npm run build:app
|
run: npm run build:app
|
||||||
@@ -33,26 +33,62 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install environment tooling
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Prepare deployment configuration
|
- name: Prepare deployment configuration
|
||||||
env:
|
env:
|
||||||
DOTENV_PRIVATE_KEY_STAGING: ${{ secrets.DOTENV_PRIVATE_KEY_STAGING }}
|
INFISICAL_APP_CLIENT_ID: ${{ vars.INFISICAL_STAGING_APP_CLIENT_ID }}
|
||||||
|
INFISICAL_APP_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_STAGING_APP_CLIENT_SECRET }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_ID:
|
||||||
|
${{ vars.INFISICAL_STAGING_SCHEDULER_CLIENT_ID }}
|
||||||
|
INFISICAL_SCHEDULER_CLIENT_SECRET:
|
||||||
|
${{ secrets.INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET }}
|
||||||
CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||||
CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }}
|
CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
BUILD_NUMBER: ${{ gitea.run_number }}
|
BUILD_NUMBER: ${{ gitea.run_number }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
umask 077
|
||||||
|
: "${INFISICAL_APP_CLIENT_ID:?INFISICAL_STAGING_APP_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_APP_CLIENT_SECRET:?INFISICAL_STAGING_APP_CLIENT_SECRET is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_ID:?INFISICAL_STAGING_SCHEDULER_CLIENT_ID is required}"
|
||||||
|
: "${INFISICAL_SCHEDULER_CLIENT_SECRET:?INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET is required}"
|
||||||
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
||||||
stage_port="${CONFIGURED_STAGE_PORT:-5569}"
|
stage_port="${CONFIGURED_STAGE_PORT:-5569}"
|
||||||
|
case "$deployment_path" in
|
||||||
|
/opt/shd-build-*) ;;
|
||||||
|
*) echo "STAGE_DEPLOY_PATH must be beneath /opt/shd-build-." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$stage_port" in
|
||||||
|
''|*[!0-9]*) echo "STAGE_PORT must be an integer." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if [ "$stage_port" -lt 1 ] || [ "$stage_port" -gt 65535 ]; then
|
||||||
|
echo "STAGE_PORT must be from 1 through 65535." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
mkdir -p "$deployment_path"
|
mkdir -p "$deployment_path"
|
||||||
cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$deployment_path/"
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
DOTENV_PRIVATE_KEY_STAGING="$DOTENV_PRIVATE_KEY_STAGING" npx dotenvx decrypt -f .env.staging --stdout > "$deployment_path/.env.staging.runtime"
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
printf 'SHD_IMAGE=shd-build-stage:%s\nSHD_STAGE_PORT=%s\nSHD_BUILD_NUMBER=%s\n' "$COMMIT_SHA" "$stage_port" "$BUILD_NUMBER" > "$deployment_path/.env.deploy"
|
rm -rf "$candidate_path"
|
||||||
chmod 600 "$deployment_path/.env.staging.runtime" "$deployment_path/.env.deploy"
|
mkdir -p "$candidate_path"
|
||||||
chmod 755 "$deployment_path/deploy-staging.sh" "$deployment_path/bootstrap-staging.sh"
|
cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$candidate_path/"
|
||||||
|
write_credential() {
|
||||||
|
target="$1"
|
||||||
|
value="$2"
|
||||||
|
temporary_file=$(mktemp "${target%/*}/.infisical.XXXXXX")
|
||||||
|
printf '%s' "$value" > "$temporary_file"
|
||||||
|
chmod 600 "$temporary_file"
|
||||||
|
mv "$temporary_file" "$target"
|
||||||
|
}
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-id" "$INFISICAL_APP_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.app.client-secret" "$INFISICAL_APP_CLIENT_SECRET"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-id" "$INFISICAL_SCHEDULER_CLIENT_ID"
|
||||||
|
write_credential "$candidate_path/.infisical.scheduler.client-secret" "$INFISICAL_SCHEDULER_CLIENT_SECRET"
|
||||||
|
temporary_deploy_environment=$(mktemp "$candidate_path/.env.deploy.XXXXXX")
|
||||||
|
printf 'SHD_IMAGE=shd-build-stage:%s-%s\nSHD_STAGE_PORT=%s\nSHD_BUILD_NUMBER=%s\nSHD_COMMIT_SHA=%s\n' "$COMMIT_SHA" "$release_id" "$stage_port" "$BUILD_NUMBER" "$COMMIT_SHA" > "$temporary_deploy_environment"
|
||||||
|
chmod 600 "$temporary_deploy_environment"
|
||||||
|
mv "$temporary_deploy_environment" "$candidate_path/.env.deploy"
|
||||||
|
chmod 755 "$candidate_path/deploy-staging.sh" "$candidate_path/bootstrap-staging.sh"
|
||||||
|
|
||||||
- name: Deploy staging
|
- name: Deploy staging
|
||||||
env:
|
env:
|
||||||
@@ -60,8 +96,12 @@ jobs:
|
|||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
run: |
|
run: |
|
||||||
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
|
||||||
"$deployment_path/deploy-staging.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-stage:$COMMIT_SHA"
|
release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
candidate_path="$deployment_path/candidate-$release_id"
|
||||||
|
"$candidate_path/deploy-staging.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-stage:$COMMIT_SHA-$release_id" "$candidate_path"
|
||||||
|
|
||||||
- name: Remove unused application images
|
- name: Remove unused application images
|
||||||
if: success()
|
if: success()
|
||||||
run: docker image prune --force --filter "label=org.opencontainers.image.title=shd-build"
|
run:
|
||||||
|
docker image prune --force --filter
|
||||||
|
"label=org.opencontainers.image.title=shd-build"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: shd-build-staging
|
group: shd-build-host-maintenance
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -25,15 +25,32 @@ jobs:
|
|||||||
compose() {
|
compose() {
|
||||||
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
}
|
}
|
||||||
restart_services() {
|
|
||||||
compose up -d --no-build app scheduler
|
|
||||||
}
|
|
||||||
trap restart_services EXIT
|
|
||||||
snapshot_name="isac-stage-${GITHUB_RUN_ID}.json"
|
snapshot_name="isac-stage-${GITHUB_RUN_ID}.json"
|
||||||
|
volume_snapshot="/app/data/exports/$snapshot_name"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
cleanup() {
|
||||||
|
exit_status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if [ "$remove_volume_snapshot" = true ]; then
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot" || true
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
compose stop scheduler app
|
compose stop scheduler app
|
||||||
compose run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name"
|
app_container="$(compose ps --all --quiet app)"
|
||||||
compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \
|
if [ -z "$app_container" ]; then
|
||||||
cp "/app/data/exports/$snapshot_name" /export/isac-stage.json
|
echo "The stopped staging app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_volume_snapshot=true
|
||||||
|
compose run --rm --no-deps app node scripts/isac-data-export.mjs "$volume_snapshot"
|
||||||
|
docker cp "$app_container:$volume_snapshot" "$GITHUB_WORKSPACE/isac-stage.json"
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
|
||||||
- name: Publish ISAC snapshot
|
- name: Publish ISAC snapshot
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: shd-build-production
|
group: shd-build-host-maintenance
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -25,15 +25,32 @@ jobs:
|
|||||||
compose() {
|
compose() {
|
||||||
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
}
|
}
|
||||||
restart_services() {
|
|
||||||
compose up -d --no-build app scheduler
|
|
||||||
}
|
|
||||||
trap restart_services EXIT
|
|
||||||
snapshot_name="isac-production-${GITHUB_RUN_ID}.json"
|
snapshot_name="isac-production-${GITHUB_RUN_ID}.json"
|
||||||
|
volume_snapshot="/app/data/exports/$snapshot_name"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
cleanup() {
|
||||||
|
exit_status=$?
|
||||||
|
trap - EXIT
|
||||||
|
if [ "$remove_volume_snapshot" = true ]; then
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot" || true
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
compose stop scheduler app
|
compose stop scheduler app
|
||||||
compose run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name"
|
app_container="$(compose ps --all --quiet app)"
|
||||||
compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \
|
if [ -z "$app_container" ]; then
|
||||||
cp "/app/data/exports/$snapshot_name" /export/isac-production.json
|
echo "The stopped production app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_volume_snapshot=true
|
||||||
|
compose run --rm --no-deps app node scripts/isac-data-export.mjs "$volume_snapshot"
|
||||||
|
docker cp "$app_container:$volume_snapshot" "$GITHUB_WORKSPACE/isac-production.json"
|
||||||
|
compose run --rm --no-deps app rm -f "$volume_snapshot"
|
||||||
|
remove_volume_snapshot=false
|
||||||
|
|
||||||
- name: Publish production ISAC snapshot
|
- name: Publish production ISAC snapshot
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: shd-build-production-to-staging
|
group: shd-build-host-maintenance
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
+7
-4
@@ -17,11 +17,10 @@ Thumbs.db
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
!.env.development
|
|
||||||
!.env.staging
|
|
||||||
!.env.production
|
|
||||||
.env.keys
|
.env.keys
|
||||||
.env.keys.*
|
.env.keys.*
|
||||||
|
.infisical.*.client-id
|
||||||
|
.infisical.*.client-secret
|
||||||
|
|
||||||
# Embedded Turso database files
|
# Embedded Turso database files
|
||||||
/data/*.db
|
/data/*.db
|
||||||
@@ -38,4 +37,8 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
_img/
|
_img/
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -2,29 +2,30 @@
|
|||||||
target: application route system
|
target: application route system
|
||||||
total_score: 28
|
total_score: 28
|
||||||
max_score: 40
|
max_score: 40
|
||||||
na_heuristics:
|
na_heuristics:
|
||||||
p0_count: 0
|
p0_count: 0
|
||||||
p1_count: 2
|
p1_count: 2
|
||||||
timestamp: 2026-07-26T15-49-23Z
|
timestamp: 2026-07-26T15-49-23Z
|
||||||
slug: src-routes
|
slug: src-routes
|
||||||
---
|
---
|
||||||
|
|
||||||
# SHD.build route-system critique
|
# SHD.build route-system critique
|
||||||
|
|
||||||
## Design Health Score
|
## Design Health Score
|
||||||
|
|
||||||
| # | Heuristic | Score | Key issue |
|
| # | Heuristic | Score | Key issue |
|
||||||
|---|---|---:|---|
|
| --------- | ------------------------------- | --------: | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 1 | Visibility of system status | 4 | ISAC exposes environment, access, mode, active navigation, and filter state clearly. |
|
| 1 | Visibility of system status | 4 | ISAC exposes environment, access, mode, active navigation, and filter state clearly. |
|
||||||
| 2 | Match system / real world | 3 | SHD vocabulary is authentic, but codes such as OSPR and WMOD need plain-language support. |
|
| 2 | Match system / real world | 3 | SHD vocabulary is authentic, but codes such as OSPR and WMOD need plain-language support. |
|
||||||
| 3 | User control and freedom | 3 | Navigation and cancellation patterns are strong; empty destinations and filter clearing are less explicit. |
|
| 3 | User control and freedom | 3 | Navigation and cancellation patterns are strong; empty destinations and filter clearing are less explicit. |
|
||||||
| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. |
|
| 4 | Consistency and standards | 3 | Shared patterns are cohesive, but live search plus Search, static-looking KPI links, and Edit-only rows diverge. |
|
||||||
| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. |
|
| 5 | Error prevention | 3 | Guardrails are extensive, but Add/Edit consequence language is not visible before entry. |
|
||||||
| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. |
|
| 6 | Recognition rather than recall | 2 | Seventeen ISAC destinations, code labels, and visually identical category cards create scanning burden. |
|
||||||
| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. |
|
| 7 | Flexibility and efficiency | 3 | Search, filtering, page sizes, and grouped navigation help experts; one-page registries expose unnecessary controls. |
|
||||||
| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. |
|
| 8 | Aesthetic and minimalist design | 2 | The system is disciplined, but cinematic hero scale and equally weighted bordered blocks compete with the task. |
|
||||||
| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. |
|
| 9 | Error recovery | 3 | The application has designed error and preservation patterns, though recovery was not exercised in the inspected states. |
|
||||||
| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. |
|
| 10 | Help and documentation | 2 | Microcopy exists, but ISAC terms, permissions, and proposal consequences lack contextual help. |
|
||||||
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
|
| **Total** | | **28/40** | **Good foundation; focused hierarchy and disclosure work needed.** |
|
||||||
|
|
||||||
## Design Specificity Verdict
|
## Design Specificity Verdict
|
||||||
|
|
||||||
@@ -36,11 +37,12 @@ blocks flatten into a generic dark administrative grid.
|
|||||||
|
|
||||||
The deterministic scan found seven source findings across `src/routes`: one
|
The deterministic scan found seven source findings across `src/routes`: one
|
||||||
warning for the two-pixel semantic notification rail in
|
warning for the two-pixel semantic notification rail in
|
||||||
`src/routes/account/+page.svelte`, and six advisory colors outside the documented
|
`src/routes/account/+page.svelte`, and six advisory colors outside the
|
||||||
palette in the homepage and account route. The rail is largely a detector false
|
documented palette in the homepage and account route. The rail is largely a
|
||||||
positive because it conveys semantic message state, but its colors should still
|
detector false positive because it conveys semantic message state, but its
|
||||||
come from named tokens. The homepage build colors are data-like values rather
|
colors should still come from named tokens. The homepage build colors are
|
||||||
than structural CSS, yet they also bypass the maintained palette.
|
data-like values rather than structural CSS, yet they also bypass the maintained
|
||||||
|
palette.
|
||||||
|
|
||||||
Mutable browser injection was unavailable, so no visual overlay is claimed.
|
Mutable browser injection was unavailable, so no visual overlay is claimed.
|
||||||
Fresh Chrome tabs, screenshots, DOM snapshots, and computed styles instead
|
Fresh Chrome tabs, screenshots, DOM snapshots, and computed styles instead
|
||||||
@@ -72,8 +74,8 @@ its complexity according to the operator's current task and role.
|
|||||||
|
|
||||||
The Attributes screen simultaneously presents 17 sidebar destinations, four
|
The Attributes screen simultaneously presents 17 sidebar destinations, four
|
||||||
system-state blocks, four KPI filters, search, pagination, row-size controls,
|
system-state blocks, four KPI filters, search, pagination, row-size controls,
|
||||||
six table columns, and repeated Edit actions. Experienced operators can scan
|
six table columns, and repeated Edit actions. Experienced operators can scan it,
|
||||||
it, but contributors and new Oversight users must decode the whole information
|
but contributors and new Oversight users must decode the whole information
|
||||||
architecture before acting.
|
architecture before acting.
|
||||||
|
|
||||||
**Fix:** make sidebar groups collapsible, prioritize modules by role and recent
|
**Fix:** make sidebar groups collapsible, prioritize modules by role and recent
|
||||||
@@ -109,8 +111,8 @@ to a clear secondary path.
|
|||||||
|
|
||||||
### P2 — Empty registries promise the same value as populated registries
|
### P2 — Empty registries promise the same value as populated registries
|
||||||
|
|
||||||
Four of eight Database cards show zero records and “Awaiting contributions,”
|
Four of eight Database cards show zero records and “Awaiting contributions,” but
|
||||||
but look and behave like full destinations. That creates repeated anticipation
|
look and behave like full destinations. That creates repeated anticipation
|
||||||
followed by a low-value endpoint.
|
followed by a low-value endpoint.
|
||||||
|
|
||||||
**Fix:** give empty registries an explicit empty-state anatomy with expected
|
**Fix:** give empty registries an explicit empty-state anatomy with expected
|
||||||
@@ -121,10 +123,10 @@ not imply the same outcome as populated data.
|
|||||||
|
|
||||||
### P2 — Functional contrast and interaction grammar remain uneven
|
### P2 — Functional contrast and interaction grammar remain uneven
|
||||||
|
|
||||||
Muted database descriptions, inactive navigation, unit sublabels, and
|
Muted database descriptions, inactive navigation, unit sublabels, and pagination
|
||||||
pagination text recede heavily. KPI filters resemble static metrics, table rows
|
text recede heavily. KPI filters resemble static metrics, table rows look
|
||||||
look selectable while only the small Edit action works, and a Search button
|
selectable while only the small Edit action works, and a Search button remains
|
||||||
remains beside an input that searches as the user types.
|
beside an input that searches as the user types.
|
||||||
|
|
||||||
**Fix:** reserve the dimmest tone for nonessential metadata, raise informative
|
**Fix:** reserve the dimmest tone for nonessential metadata, raise informative
|
||||||
copy and available navigation one contrast step, make KPI filter state
|
copy and available navigation one contrast step, make KPI filter state
|
||||||
@@ -147,9 +149,9 @@ seven-plus pagination/search controls, and 22 repeated Edit actions.
|
|||||||
The Database hero creates a confident, premium entry peak. Populated cards
|
The Database hero creates a confident, premium entry peak. Populated cards
|
||||||
promise useful intelligence, but visually equal empty categories produce a
|
promise useful intelligence, but visually equal empty categories produce a
|
||||||
disappointment valley. ISAC's “Online / Authorized / Admin” state reassures
|
disappointment valley. ISAC's “Online / Authorized / Admin” state reassures
|
||||||
operators, then the uniform density creates fatigue with little calm focus.
|
operators, then the uniform density creates fatigue with little calm focus. The
|
||||||
The most important missing reassurance is a plain statement of what Add/Edit
|
most important missing reassurance is a plain statement of what Add/Edit will do
|
||||||
will do to live data.
|
to live data.
|
||||||
|
|
||||||
## Persona Red Flags
|
## Persona Red Flags
|
||||||
|
|
||||||
|
|||||||
+220
-22
@@ -8,133 +8,331 @@
|
|||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Operations Black",
|
"displayName": "Operations Black",
|
||||||
"canonical": "#0a0c0d",
|
"canonical": "#0a0c0d",
|
||||||
"tonalRamp": ["#050606", "#0a0c0d", "#171a1b", "#303536", "#555c5e", "#858b8d", "#c1c4c2", "#f0f0eb"]
|
"tonalRamp": [
|
||||||
|
"#050606",
|
||||||
|
"#0a0c0d",
|
||||||
|
"#171a1b",
|
||||||
|
"#303536",
|
||||||
|
"#555c5e",
|
||||||
|
"#858b8d",
|
||||||
|
"#c1c4c2",
|
||||||
|
"#f0f0eb"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"terminal-black": {
|
"terminal-black": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Terminal Black",
|
"displayName": "Terminal Black",
|
||||||
"canonical": "#0d0f10",
|
"canonical": "#0d0f10",
|
||||||
"tonalRamp": ["#050606", "#0d0f10", "#1b1e1f", "#34393a", "#596062", "#888f91", "#c3c7c6", "#f1f2ee"]
|
"tonalRamp": [
|
||||||
|
"#050606",
|
||||||
|
"#0d0f10",
|
||||||
|
"#1b1e1f",
|
||||||
|
"#34393a",
|
||||||
|
"#596062",
|
||||||
|
"#888f91",
|
||||||
|
"#c3c7c6",
|
||||||
|
"#f1f2ee"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"terminal-surface": {
|
"terminal-surface": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Terminal Surface",
|
"displayName": "Terminal Surface",
|
||||||
"canonical": "#111415",
|
"canonical": "#111415",
|
||||||
"tonalRamp": ["#070808", "#111415", "#202425", "#393e3f", "#5f6668", "#8d9496", "#c6cac9", "#f2f3ef"]
|
"tonalRamp": [
|
||||||
|
"#070808",
|
||||||
|
"#111415",
|
||||||
|
"#202425",
|
||||||
|
"#393e3f",
|
||||||
|
"#5f6668",
|
||||||
|
"#8d9496",
|
||||||
|
"#c6cac9",
|
||||||
|
"#f2f3ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"terminal-graphite": {
|
"terminal-graphite": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Terminal Graphite",
|
"displayName": "Terminal Graphite",
|
||||||
"canonical": "#141718",
|
"canonical": "#141718",
|
||||||
"tonalRamp": ["#080a0a", "#111415", "#202425", "#343a3b", "#565e60", "#858d8f", "#c0c5c3", "#f0f1ed"]
|
"tonalRamp": [
|
||||||
|
"#080a0a",
|
||||||
|
"#111415",
|
||||||
|
"#202425",
|
||||||
|
"#343a3b",
|
||||||
|
"#565e60",
|
||||||
|
"#858d8f",
|
||||||
|
"#c0c5c3",
|
||||||
|
"#f0f1ed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"active-graphite": {
|
"active-graphite": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Active Graphite",
|
"displayName": "Active Graphite",
|
||||||
"canonical": "#15191a",
|
"canonical": "#15191a",
|
||||||
"tonalRamp": ["#080a0a", "#15191a", "#252a2b", "#3e4445", "#626a6c", "#909799", "#c8cccb", "#f2f3f0"]
|
"tonalRamp": [
|
||||||
|
"#080a0a",
|
||||||
|
"#15191a",
|
||||||
|
"#252a2b",
|
||||||
|
"#3e4445",
|
||||||
|
"#626a6c",
|
||||||
|
"#909799",
|
||||||
|
"#c8cccb",
|
||||||
|
"#f2f3f0"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"modeled-data-surface": {
|
"modeled-data-surface": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Modeled Data Surface",
|
"displayName": "Modeled Data Surface",
|
||||||
"canonical": "#0d1213",
|
"canonical": "#0d1213",
|
||||||
"tonalRamp": ["#050708", "#0d1213", "#1c2527", "#334043", "#526165", "#7d8b8e", "#bac2c2", "#eff1ef"]
|
"tonalRamp": [
|
||||||
|
"#050708",
|
||||||
|
"#0d1213",
|
||||||
|
"#1c2527",
|
||||||
|
"#334043",
|
||||||
|
"#526165",
|
||||||
|
"#7d8b8e",
|
||||||
|
"#bac2c2",
|
||||||
|
"#eff1ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"structural-line": {
|
"structural-line": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Structural Line",
|
"displayName": "Structural Line",
|
||||||
"canonical": "#303536",
|
"canonical": "#303536",
|
||||||
"tonalRamp": ["#0d0f10", "#1b1f20", "#303536", "#4b5152", "#6a7274", "#92999b", "#c3c8c7", "#f0f1ee"]
|
"tonalRamp": [
|
||||||
|
"#0d0f10",
|
||||||
|
"#1b1f20",
|
||||||
|
"#303536",
|
||||||
|
"#4b5152",
|
||||||
|
"#6a7274",
|
||||||
|
"#92999b",
|
||||||
|
"#c3c8c7",
|
||||||
|
"#f0f1ee"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"structural-line-soft": {
|
"structural-line-soft": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Structural Line Soft",
|
"displayName": "Structural Line Soft",
|
||||||
"canonical": "#272c2d",
|
"canonical": "#272c2d",
|
||||||
"tonalRamp": ["#0b0d0d", "#181b1c", "#272c2d", "#43494a", "#656d6f", "#8e9597", "#c2c6c5", "#eff0ed"]
|
"tonalRamp": [
|
||||||
|
"#0b0d0d",
|
||||||
|
"#181b1c",
|
||||||
|
"#272c2d",
|
||||||
|
"#43494a",
|
||||||
|
"#656d6f",
|
||||||
|
"#8e9597",
|
||||||
|
"#c2c6c5",
|
||||||
|
"#eff0ed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"structural-line-strong": {
|
"structural-line-strong": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Structural Line Strong",
|
"displayName": "Structural Line Strong",
|
||||||
"canonical": "#424849",
|
"canonical": "#424849",
|
||||||
"tonalRamp": ["#111415", "#252a2b", "#424849", "#5d6567", "#7c8587", "#a0a7a8", "#c9cdcc", "#f1f2ef"]
|
"tonalRamp": [
|
||||||
|
"#111415",
|
||||||
|
"#252a2b",
|
||||||
|
"#424849",
|
||||||
|
"#5d6567",
|
||||||
|
"#7c8587",
|
||||||
|
"#a0a7a8",
|
||||||
|
"#c9cdcc",
|
||||||
|
"#f1f2ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"modeled-data-line": {
|
"modeled-data-line": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Modeled Data Line",
|
"displayName": "Modeled Data Line",
|
||||||
"canonical": "#3b4648",
|
"canonical": "#3b4648",
|
||||||
"tonalRamp": ["#0e1314", "#20292b", "#3b4648", "#566366", "#758184", "#9aa3a5", "#c6cbca", "#f0f2ef"]
|
"tonalRamp": [
|
||||||
|
"#0e1314",
|
||||||
|
"#20292b",
|
||||||
|
"#3b4648",
|
||||||
|
"#566366",
|
||||||
|
"#758184",
|
||||||
|
"#9aa3a5",
|
||||||
|
"#c6cbca",
|
||||||
|
"#f0f2ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"field-manual-white": {
|
"field-manual-white": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Field Manual White",
|
"displayName": "Field Manual White",
|
||||||
"canonical": "#ecebe5",
|
"canonical": "#ecebe5",
|
||||||
"tonalRamp": ["#252522", "#44433f", "#66655f", "#898780", "#aaa8a1", "#cac8c1", "#ecebe5", "#faf9f5"]
|
"tonalRamp": [
|
||||||
|
"#252522",
|
||||||
|
"#44433f",
|
||||||
|
"#66655f",
|
||||||
|
"#898780",
|
||||||
|
"#aaa8a1",
|
||||||
|
"#cac8c1",
|
||||||
|
"#ecebe5",
|
||||||
|
"#faf9f5"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"telemetry-muted": {
|
"telemetry-muted": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Telemetry Muted",
|
"displayName": "Telemetry Muted",
|
||||||
"canonical": "#858b8d",
|
"canonical": "#858b8d",
|
||||||
"tonalRamp": ["#151718", "#292d2e", "#414647", "#5e6466", "#858b8d", "#a6abad", "#cbd0cf", "#f1f2ef"]
|
"tonalRamp": [
|
||||||
|
"#151718",
|
||||||
|
"#292d2e",
|
||||||
|
"#414647",
|
||||||
|
"#5e6466",
|
||||||
|
"#858b8d",
|
||||||
|
"#a6abad",
|
||||||
|
"#cbd0cf",
|
||||||
|
"#f1f2ef"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"telemetry-dim": {
|
"telemetry-dim": {
|
||||||
"role": "neutral",
|
"role": "neutral",
|
||||||
"displayName": "Telemetry Dim",
|
"displayName": "Telemetry Dim",
|
||||||
"canonical": "#5d6567",
|
"canonical": "#5d6567",
|
||||||
"tonalRamp": ["#111314", "#222627", "#373c3d", "#4b5254", "#5d6567", "#858d8f", "#bcc1c0", "#eef0ed"]
|
"tonalRamp": [
|
||||||
|
"#111314",
|
||||||
|
"#222627",
|
||||||
|
"#373c3d",
|
||||||
|
"#4b5254",
|
||||||
|
"#5d6567",
|
||||||
|
"#858d8f",
|
||||||
|
"#bcc1c0",
|
||||||
|
"#eef0ed"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"signal-orange": {
|
"signal-orange": {
|
||||||
"role": "primary",
|
"role": "primary",
|
||||||
"displayName": "Signal Orange",
|
"displayName": "Signal Orange",
|
||||||
"canonical": "#f6a821",
|
"canonical": "#f6a821",
|
||||||
"tonalRamp": ["#2b1a05", "#4c2f08", "#754a0d", "#a66c14", "#d7901c", "#f6a821", "#ffd278", "#fff1cf"]
|
"tonalRamp": [
|
||||||
|
"#2b1a05",
|
||||||
|
"#4c2f08",
|
||||||
|
"#754a0d",
|
||||||
|
"#a66c14",
|
||||||
|
"#d7901c",
|
||||||
|
"#f6a821",
|
||||||
|
"#ffd278",
|
||||||
|
"#fff1cf"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"signal-orange-hover": {
|
"signal-orange-hover": {
|
||||||
"role": "primary",
|
"role": "primary",
|
||||||
"displayName": "Signal Orange Hover",
|
"displayName": "Signal Orange Hover",
|
||||||
"canonical": "#ffc45d",
|
"canonical": "#ffc45d",
|
||||||
"tonalRamp": ["#2d1f08", "#51380d", "#785515", "#a97824", "#d99d39", "#ffc45d", "#ffdda0", "#fff3d9"]
|
"tonalRamp": [
|
||||||
|
"#2d1f08",
|
||||||
|
"#51380d",
|
||||||
|
"#785515",
|
||||||
|
"#a97824",
|
||||||
|
"#d99d39",
|
||||||
|
"#ffc45d",
|
||||||
|
"#ffdda0",
|
||||||
|
"#fff3d9"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"system-success": {
|
"system-success": {
|
||||||
"role": "secondary",
|
"role": "secondary",
|
||||||
"displayName": "System Success",
|
"displayName": "System Success",
|
||||||
"canonical": "#75c08c",
|
"canonical": "#75c08c",
|
||||||
"tonalRamp": ["#102a19", "#194328", "#275f39", "#3f7d50", "#5c9e6d", "#75c08c", "#a8dcba", "#e2f4e8"]
|
"tonalRamp": [
|
||||||
|
"#102a19",
|
||||||
|
"#194328",
|
||||||
|
"#275f39",
|
||||||
|
"#3f7d50",
|
||||||
|
"#5c9e6d",
|
||||||
|
"#75c08c",
|
||||||
|
"#a8dcba",
|
||||||
|
"#e2f4e8"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"system-danger": {
|
"system-danger": {
|
||||||
"role": "secondary",
|
"role": "secondary",
|
||||||
"displayName": "System Danger",
|
"displayName": "System Danger",
|
||||||
"canonical": "#df8069",
|
"canonical": "#df8069",
|
||||||
"tonalRamp": ["#32130e", "#542219", "#793428", "#a54a39", "#c9634f", "#df8069", "#edae9f", "#f8dfd9"]
|
"tonalRamp": [
|
||||||
|
"#32130e",
|
||||||
|
"#542219",
|
||||||
|
"#793428",
|
||||||
|
"#a54a39",
|
||||||
|
"#c9634f",
|
||||||
|
"#df8069",
|
||||||
|
"#edae9f",
|
||||||
|
"#f8dfd9"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"system-info": {
|
"system-info": {
|
||||||
"role": "secondary",
|
"role": "secondary",
|
||||||
"displayName": "System Information",
|
"displayName": "System Information",
|
||||||
"canonical": "#70b9d6",
|
"canonical": "#70b9d6",
|
||||||
"tonalRamp": ["#0d2630", "#163f50", "#245b70", "#387991", "#5599b3", "#70b9d6", "#a6d5e7", "#e1f1f7"]
|
"tonalRamp": [
|
||||||
|
"#0d2630",
|
||||||
|
"#163f50",
|
||||||
|
"#245b70",
|
||||||
|
"#387991",
|
||||||
|
"#5599b3",
|
||||||
|
"#70b9d6",
|
||||||
|
"#a6d5e7",
|
||||||
|
"#e1f1f7"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-standard": {
|
"quality-standard": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Standard Quality",
|
"displayName": "Standard Quality",
|
||||||
"canonical": "#69b66f",
|
"canonical": "#69b66f",
|
||||||
"tonalRamp": ["#0e2912", "#17431d", "#255e2b", "#397b3e", "#519a56", "#69b66f", "#9dd4a1", "#dff0e0"]
|
"tonalRamp": [
|
||||||
|
"#0e2912",
|
||||||
|
"#17431d",
|
||||||
|
"#255e2b",
|
||||||
|
"#397b3e",
|
||||||
|
"#519a56",
|
||||||
|
"#69b66f",
|
||||||
|
"#9dd4a1",
|
||||||
|
"#dff0e0"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-specialized": {
|
"quality-specialized": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Specialized Quality",
|
"displayName": "Specialized Quality",
|
||||||
"canonical": "#4f9ed8",
|
"canonical": "#4f9ed8",
|
||||||
"tonalRamp": ["#0c2435", "#123c59", "#1d567d", "#2d72a3", "#408dc4", "#4f9ed8", "#8dc5ea", "#d9edf8"]
|
"tonalRamp": [
|
||||||
|
"#0c2435",
|
||||||
|
"#123c59",
|
||||||
|
"#1d567d",
|
||||||
|
"#2d72a3",
|
||||||
|
"#408dc4",
|
||||||
|
"#4f9ed8",
|
||||||
|
"#8dc5ea",
|
||||||
|
"#d9edf8"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-superior": {
|
"quality-superior": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "Superior Quality",
|
"displayName": "Superior Quality",
|
||||||
"canonical": "#9b68d4",
|
"canonical": "#9b68d4",
|
||||||
"tonalRamp": ["#21132f", "#38204f", "#522f70", "#704594", "#8959b7", "#9b68d4", "#c3a2e5", "#eee1f7"]
|
"tonalRamp": [
|
||||||
|
"#21132f",
|
||||||
|
"#38204f",
|
||||||
|
"#522f70",
|
||||||
|
"#704594",
|
||||||
|
"#8959b7",
|
||||||
|
"#9b68d4",
|
||||||
|
"#c3a2e5",
|
||||||
|
"#eee1f7"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"quality-high-end": {
|
"quality-high-end": {
|
||||||
"role": "tertiary",
|
"role": "tertiary",
|
||||||
"displayName": "High-end Quality",
|
"displayName": "High-end Quality",
|
||||||
"canonical": "#e4bd3f",
|
"canonical": "#e4bd3f",
|
||||||
"tonalRamp": ["#2d2408", "#4c3c0d", "#6f5815", "#95771f", "#bd992d", "#e4bd3f", "#f0d982", "#fbf2ce"]
|
"tonalRamp": [
|
||||||
|
"#2d2408",
|
||||||
|
"#4c3c0d",
|
||||||
|
"#6f5815",
|
||||||
|
"#95771f",
|
||||||
|
"#bd992d",
|
||||||
|
"#e4bd3f",
|
||||||
|
"#f0d982",
|
||||||
|
"#fbf2ce"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typographyMeta": {
|
"typographyMeta": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"workspaceId": "2ee94b27-e599-4194-9797-3372d42e53f6",
|
||||||
|
"defaultEnvironment": "dev",
|
||||||
|
"defaultSecretPath": "/app",
|
||||||
|
"domain": "https://secrets.jb.codes"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
tmp
|
||||||
|
_img
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"insertPragma": false,
|
||||||
|
"jsxBracketSameLine": false,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"requirePragma": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,70 @@
|
|||||||
|
|
||||||
## Parallel task execution
|
## Parallel task execution
|
||||||
|
|
||||||
For substantial changes, use subagents as a standard practice when the work can be divided into concrete, independent tasks that provide real parallel progress.
|
For substantial changes, use subagents as a standard practice when the work can
|
||||||
|
be divided into concrete, independent tasks that provide real parallel progress.
|
||||||
|
|
||||||
1. Delegate bounded mechanical work to faster, lower-reasoning agents (focused code discovery, repetitive call-site updates, documentation, isolated test coverage).
|
1. Delegate bounded mechanical work to faster, lower-reasoning agents (focused
|
||||||
2. Keep architecture, security-sensitive behavior, database migrations, shared data-model decisions, and complex debugging with a suitably capable agent.
|
code discovery, repetitive call-site updates, documentation, isolated test
|
||||||
3. Assign explicit file or subsystem ownership before parallel edits. Avoid having multiple agents edit the same files unless their work is deliberately sequenced.
|
coverage).
|
||||||
4. Do not delegate tightly coupled work merely to increase agent count. Keep it with the primary agent when coordination would cost more than it saves.
|
2. Keep architecture, security-sensitive behavior, database migrations, shared
|
||||||
5. The primary agent remains responsible for integrating results, reviewing diffs, resolving cross-cutting concerns, and running the final required validation.
|
data-model decisions, and complex debugging with a suitably capable agent.
|
||||||
6. Subagents must follow every repository rule that applies to their assigned work, including design-system maintenance, audit logging, and database preservation.
|
3. Assign explicit file or subsystem ownership before parallel edits. Avoid
|
||||||
|
having multiple agents edit the same files unless their work is deliberately
|
||||||
|
sequenced.
|
||||||
|
4. Do not delegate tightly coupled work merely to increase agent count. Keep it
|
||||||
|
with the primary agent when coordination would cost more than it saves.
|
||||||
|
5. The primary agent remains responsible for integrating results, reviewing
|
||||||
|
diffs, resolving cross-cutting concerns, and running the final required
|
||||||
|
validation.
|
||||||
|
6. Subagents must follow every repository rule that applies to their assigned
|
||||||
|
work, including design-system maintenance, audit logging, and database
|
||||||
|
preservation.
|
||||||
|
|
||||||
## Documentation prose
|
## Documentation prose
|
||||||
|
|
||||||
Use the `stop-slop` skill whenever an agent creates or changes project documentation. Apply its review to the complete affected document, including sections that the change did not introduce. Preserve technical meaning, commands, paths, formulas, links, policy strength, and product terminology. Remove filler, vague claims, formulaic contrasts, manufactured emphasis, false agency, and repetition. Prefer active voice and specific subjects. Do not weaken terms such as `must`, `never`, or `always` when they express a real requirement.
|
Use the `stop-slop` skill whenever an agent creates or changes project
|
||||||
|
documentation. Apply its review to the complete affected document, including
|
||||||
|
sections that the change did not introduce. Preserve technical meaning,
|
||||||
|
commands, paths, formulas, links, policy strength, and product terminology.
|
||||||
|
Remove filler, vague claims, formulaic contrasts, manufactured emphasis, false
|
||||||
|
agency, and repetition. Prefer active voice and specific subjects. Do not weaken
|
||||||
|
terms such as `must`, `never`, or `always` when they express a real requirement.
|
||||||
|
|
||||||
## Dependency source research
|
## Dependency source research
|
||||||
|
|
||||||
Use `npx opensrc` before inspecting an npm dependency's implementation. It resolves the package version from this workspace's lockfile, shallow-clones the matching source revision, and caches it outside the repository at `~/.opensrc/`.
|
Use `npx opensrc` before inspecting an npm dependency's implementation. It
|
||||||
|
resolves the package version from this workspace's lockfile, shallow-clones the
|
||||||
|
matching source revision, and caches it outside the repository at `~/.opensrc/`.
|
||||||
|
|
||||||
- Do not guess at dependency behavior or implementation details. Identify the needed function, type, option, or control flow in the cached source and use that evidence when designing a change.
|
- Do not guess at dependency behavior or implementation details. Identify the
|
||||||
- Search the smallest relevant source area first. Use the package entry point, exports, types, and targeted code search to locate the behavior instead of reading unrelated files.
|
needed function, type, option, or control flow in the cached source and use
|
||||||
- Fetch source before a dependency investigation: `npx opensrc fetch --cwd . <package>`.
|
that evidence when designing a change.
|
||||||
- Print the cached source path for use with repository search and file tools: `npx opensrc path --cwd . <package>`.
|
- Search the smallest relevant source area first. Use the package entry point,
|
||||||
- Fetch several declared packages in one command: `npx opensrc fetch --cwd . <package-a> <package-b>`.
|
exports, types, and targeted code search to locate the behavior instead of
|
||||||
- After adding or upgrading a direct dependency, fetch its source at the locked version. Do not commit `~/.opensrc/` or copy dependency source into this repository.
|
reading unrelated files.
|
||||||
- Use `npx opensrc list` to inspect the cache. Use `npx opensrc remove <package>` or `npx opensrc clean` only when cache removal is intended.
|
- Fetch source before a dependency investigation:
|
||||||
|
`npx opensrc fetch --cwd . <package>`.
|
||||||
|
- Print the cached source path for use with repository search and file tools:
|
||||||
|
`npx opensrc path --cwd . <package>`.
|
||||||
|
- Fetch several declared packages in one command:
|
||||||
|
`npx opensrc fetch --cwd . <package-a> <package-b>`.
|
||||||
|
- After adding or upgrading a direct dependency, fetch its source at the locked
|
||||||
|
version. Do not commit `~/.opensrc/` or copy dependency source into this
|
||||||
|
repository.
|
||||||
|
- Use `npx opensrc list` to inspect the cache. Use
|
||||||
|
`npx opensrc remove <package>` or `npx opensrc clean` only when cache removal
|
||||||
|
is intended.
|
||||||
|
|
||||||
## Available skills
|
## Available skills
|
||||||
|
|
||||||
The `.agents/skills/` directory contains repo-specific skill files. Key skills:
|
The `.agents/skills/` directory contains repo-specific skill files. Key skills:
|
||||||
|
|
||||||
- `stop-slop` — apply to documentation prose changes
|
- `stop-slop`: apply to documentation prose changes
|
||||||
- `frontend-design` / `impeccable` — UI design, refinement, and polish
|
- `frontend-design` / `impeccable`: UI design, refinement, and polish
|
||||||
- `import-talent-screenshot` / `import-weapon-screenshot` — extract Division Resurgence game data from screenshots and enter through ISAC
|
- `import-talent-screenshot` / `import-weapon-screenshot`: extract Division
|
||||||
- `webapp-testing` — Playwright-based local web app testing
|
Resurgence game data from screenshots and enter through ISAC
|
||||||
|
- `webapp-testing`: Playwright-based local web app testing
|
||||||
|
|
||||||
Use `skill` tool to load one when the task matches.
|
Use `skill` tool to load one when the task matches.
|
||||||
|
|
||||||
@@ -44,103 +75,183 @@ Run in order after source changes:
|
|||||||
|
|
||||||
```
|
```
|
||||||
npm run check # svelte-kit sync + svelte-check (typecheck)
|
npm run check # svelte-kit sync + svelte-check (typecheck)
|
||||||
npm run build # dotenvx production env + vite build
|
npm run build # secretless Vite production build
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run check` runs `svelte-kit sync` (generates `.svelte-kit/tsconfig.json`) then `svelte-check --tsconfig ./tsconfig.json`. `npm test` runs all vitest tests; there is no separate vitest config (vitest inherits from `vite.config.ts`).
|
`npm run check` runs `svelte-kit sync` (generates `.svelte-kit/tsconfig.json`)
|
||||||
|
then `svelte-check --tsconfig ./tsconfig.json`. `npm test` runs all vitest
|
||||||
|
tests; there is no separate vitest config (vitest inherits from
|
||||||
|
`vite.config.ts`).
|
||||||
|
|
||||||
Test files (`*.test.ts`) live adjacent to the code they test, mostly in `src/lib/`. Most server-unit tests mock `$lib/server/database` via `vi.mock(...)` — unnecessary for unit tests against pure logic. Integration tests that need database state use the embedded Turso engine.
|
Test files (`*.test.ts`) live adjacent to the code they test, mostly in
|
||||||
|
`src/lib/`. Most server-unit tests mock `$lib/server/database` via
|
||||||
|
`vi.mock(...)`; pure logic tests do not need that mock. Integration tests that
|
||||||
|
need database state use the embedded Turso engine.
|
||||||
|
|
||||||
## Framework and toolchain quirks
|
## Framework and toolchain quirks
|
||||||
|
|
||||||
- **SvelteKit 2 / Svelte 5** with forced runes mode (configured in `vite.config.ts` via plugin `compilerOptions.runes`). There is no `svelte.config.js` — SvelteKit config lives in `vite.config.ts`.
|
- **SvelteKit 2 / Svelte 5** with forced runes mode (configured in
|
||||||
- **Node adapter** (`@sveltejs/adapter-node`). Production entry point is `node build`. The container runs migrations on startup, then serves.
|
`vite.config.ts` via plugin `compilerOptions.runes`). There is no
|
||||||
- **Embedded Turso** (`@tursodatabase/database`) — local SQLite, no Turso Cloud, no libSQL HTTP, no replicas. The dev database path is `./data/shd-build-development.db` (set in `.env.development`).
|
`svelte.config.js`; SvelteKit config lives in `vite.config.ts`.
|
||||||
- **Passkey auth** (`@simplewebauthn`) is preferred. `APP_ORIGIN` and `PASSKEY_RP_ID` must match the public origin.
|
- **Node adapter** (`@sveltejs/adapter-node`). Production entry point is
|
||||||
- **Environment files** are encrypted with `dotenvx`. Committed `.env.development`, `.env.staging`, `.env.production` are encrypted; `.env.keys` is git-ignored. The pre-commit hook runs `npm run env:stage` (encrypts and git-adds). To edit: `npm run env:decrypt`, make changes, `npm run env:stage`.
|
`node build`. The container runs migrations on startup, then serves.
|
||||||
|
- **Embedded Turso** (`@tursodatabase/database`): local SQLite, no Turso Cloud,
|
||||||
|
no libSQL HTTP, no replicas. The Infisical `dev` environment sets the database
|
||||||
|
path to `./data/shd-build-development.db`.
|
||||||
|
- **Passkey auth** (`@simplewebauthn`) is preferred. `APP_ORIGIN` must match the
|
||||||
|
public origin. `PASSKEY_RP_ID` must be that hostname or a registrable parent
|
||||||
|
domain; hosted environments use `shd.build`.
|
||||||
|
- **Infisical secrets** live in project `2ee94b27-e599-4194-9797-3372d42e53f6`
|
||||||
|
at `https://secrets.jb.codes`. Application values use `/app`;
|
||||||
|
`SCHEDULER_TOKEN` uses `/shared/scheduler`. Local commands use the
|
||||||
|
authenticated Infisical CLI and the `dev` environment. Deployed app and
|
||||||
|
scheduler containers use separate Universal Auth bootstrap credentials mounted
|
||||||
|
through Compose secrets. The root entrypoint exchanges those credentials for
|
||||||
|
one-shot JSON exports, deletes the export files, and starts the workload as
|
||||||
|
the dedicated `shd-runtime` UID 10001 without Infisical credentials or tokens.
|
||||||
|
The self-hosted Free plan cannot scope Viewer identities by environment or
|
||||||
|
path.
|
||||||
- **Argon2id** password hashing with server-side sessions and CSRF protection.
|
- **Argon2id** password hashing with server-side sessions and CSRF protection.
|
||||||
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there via 308.
|
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
|
||||||
|
via 308.
|
||||||
|
|
||||||
## Development server lifecycle
|
## Development server lifecycle
|
||||||
|
|
||||||
After changing source code, styles, configuration, dependencies, or routes, restart the development server before handing work back.
|
After changing source code, styles, configuration, dependencies, or routes,
|
||||||
|
restart the development server before handing work back.
|
||||||
|
|
||||||
1. Inspect running processes and stop only this project's development-server process tree.
|
1. Inspect running processes and stop only this project's development-server
|
||||||
2. Start one hidden `npm run dev` process from the repository root. Do not leave duplicate project development servers running.
|
process tree.
|
||||||
|
2. Start one hidden `npm run dev` process from the repository root. Do not leave
|
||||||
|
duplicate project development servers running.
|
||||||
3. Confirm the server accepts an HTTP request before completing the task.
|
3. Confirm the server accepts an HTTP request before completing the task.
|
||||||
4. Leave the verified development server running so the project owner can review the changes.
|
4. Leave the verified development server running so the project owner can review
|
||||||
|
the changes.
|
||||||
|
|
||||||
Database maintenance may require the server to remain stopped while the embedded database is locked. Restart and verify the server after maintenance completes.
|
Database maintenance may require the server to remain stopped while the embedded
|
||||||
|
database is locked. Restart and verify the server after maintenance completes.
|
||||||
|
|
||||||
## SHD design-system maintenance
|
## SHD design-system maintenance
|
||||||
|
|
||||||
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens in `src/app.css` as maintained parts of the product. When a change makes a significant visual or interaction-design decision, update the design system in the same change.
|
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens in `src/app.css` as
|
||||||
|
maintained parts of the product. When a change makes a significant visual or
|
||||||
|
interaction-design decision, update the design system in the same change.
|
||||||
|
|
||||||
For significant design changes: reuse or extend CSS custom properties from `src/app.css`; update `docs/DESIGN_SYSTEM.md`; refactor repeated UI into a reusable component; verify hover, pressed, focus-visible, pending, disabled, error, success, and reduced-motion behavior. Run `npm run check && npm run build` before completing.
|
For significant design changes: reuse or extend CSS custom properties from
|
||||||
|
`src/app.css`; update `docs/DESIGN_SYSTEM.md`; refactor repeated UI into a
|
||||||
|
reusable component; verify hover, pressed, focus-visible, pending, disabled,
|
||||||
|
error, success, and reduced-motion behavior. Run
|
||||||
|
`npm run check && npm run build` before completing.
|
||||||
|
|
||||||
Design principles: tactical but restrained, data-first, square and engineered, orange reserved for action/emphasis, explicit textual state indicators.
|
Design principles: tactical but restrained, data-first, square and engineered,
|
||||||
|
orange reserved for action/emphasis, explicit textual state indicators.
|
||||||
|
|
||||||
Minor alignment fixes, isolated bug fixes, and corrections that do not introduce a reusable design decision do not require a documentation update.
|
Minor alignment fixes, isolated bug fixes, and corrections that do not introduce
|
||||||
|
a reusable design decision do not require a documentation update.
|
||||||
|
|
||||||
## Reusable interface patterns
|
## Reusable interface patterns
|
||||||
|
|
||||||
Favor shared components from `src/lib/components`. Before adding page-local markup, check if a shared component covers the need. When introducing a reusable pattern, migrate the clearest existing duplicate in the same change.
|
Favor shared components from `src/lib/components`. Before adding page-local
|
||||||
|
markup, check if a shared component covers the need. When introducing a reusable
|
||||||
|
pattern, migrate the clearest existing duplicate in the same change.
|
||||||
|
|
||||||
For small, finite option sets, prefer the shared segmented-choice pattern over a dropdown. Use radio semantics for exactly one choice and checkbox semantics for additive independent choices. Reserve dropdowns for long, dynamic, or search-oriented option sets.
|
For small, finite option sets, prefer the shared segmented-choice pattern over a
|
||||||
|
dropdown. Use radio semantics for exactly one choice and checkbox semantics for
|
||||||
|
additive independent choices. Reserve dropdowns for long, dynamic, or
|
||||||
|
search-oriented option sets.
|
||||||
|
|
||||||
## ISAC editor lifecycle
|
## ISAC editor lifecycle
|
||||||
|
|
||||||
All ISAC data-management editors must follow the same successful-save behavior:
|
All ISAC data-management editors must follow the same successful-save behavior:
|
||||||
|
|
||||||
1. Saving an edit to an existing record closes the editor and returns the user to the registry.
|
1. Saving an edit to an existing record closes the editor and returns the user
|
||||||
|
to the registry.
|
||||||
2. Saving a newly added record keeps the editor open with a fresh blank record.
|
2. Saving a newly added record keeps the editor open with a fresh blank record.
|
||||||
3. Validation errors keep the editor open and preserve the entered values.
|
3. Validation errors keep the editor open and preserve the entered values.
|
||||||
4. Server actions must return the add/edit mode and a unique result token; client-side reactive handling must consume each token only once to avoid repeated state updates.
|
4. Server actions must return the add/edit mode and a unique result token;
|
||||||
5. When a successful add keeps an icon-enabled editor open, increment the shared upload reset token so the file input, preview, filename, drag/paste state, validation message, and removal state are fresh for the next record.
|
client-side reactive handling must consume each token only once to avoid
|
||||||
|
repeated state updates.
|
||||||
|
5. When a successful add keeps an icon-enabled editor open, increment the shared
|
||||||
|
upload reset token so the file input, preview, filename, drag/paste state,
|
||||||
|
validation message, and removal state are fresh for the next record.
|
||||||
|
|
||||||
Apply this convention whenever an ISAC editor is added or materially changed.
|
Apply this convention whenever an ISAC editor is added or materially changed.
|
||||||
|
|
||||||
## ISAC audit logging
|
## ISAC audit logging
|
||||||
|
|
||||||
Every successful server-side ISAC mutation must create an append-only audit event. Follow these rules:
|
Every successful server-side ISAC mutation must create an append-only audit
|
||||||
|
event. Follow these rules:
|
||||||
|
|
||||||
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not write directly to `audit_log`.
|
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not write
|
||||||
2. Record the authenticated operator ID, stable category, specific action (namespaced past tense, e.g. `weapon.updated`), target entity ID, timestamp, and useful before/after snapshots. Categories include `game_data`, `user_access`, `user_moderation`, `identity`, `role_management`.
|
directly to `audit_log`.
|
||||||
3. Capture changes to the primary row and its relationships. Include meaningful previous and resulting sets for linked records.
|
2. Record the authenticated operator ID, stable category, specific action
|
||||||
4. Create audit records only after validation succeeds. Rejected or failed requests must not appear as successful mutation events.
|
(namespaced past tense, e.g. `weapon.updated`), target entity ID, timestamp,
|
||||||
5. Never include password hashes, session tokens, magic-link tokens, OAuth state, passkey public-key bytes, SMTP credentials, or environment secrets in audit snapshots.
|
and useful before/after snapshots. Categories include `game_data`,
|
||||||
6. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role grants `isac.access` and `audit.view` without granting mutation permissions.
|
`user_access`, `user_moderation`, `identity`, `role_management`.
|
||||||
7. Audit reversion is a compensating action, never a history rewrite. Require `audit.revert`, confirmation, current-state comparison, relationship validation, and a new `audit.reverted` event.
|
3. Capture changes to the primary row and its relationships. Include meaningful
|
||||||
8. Tests for an ISAC mutation must confirm its audit event contains the correct actor, category, action, target, and change evidence.
|
previous and resulting sets for linked records.
|
||||||
|
4. Create audit records only after validation succeeds. Rejected or failed
|
||||||
|
requests must not appear as successful mutation events.
|
||||||
|
5. Never include password hashes, session tokens, magic-link tokens, OAuth
|
||||||
|
state, passkey public-key bytes, SMTP credentials, or environment secrets in
|
||||||
|
audit snapshots.
|
||||||
|
6. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role
|
||||||
|
grants `isac.access` and `audit.view` without granting mutation permissions.
|
||||||
|
7. Audit reversion is a compensating action, never a history rewrite. Require
|
||||||
|
`audit.revert`, confirmation, current-state comparison, relationship
|
||||||
|
validation, and a new `audit.reverted` event.
|
||||||
|
8. Tests for an ISAC mutation must confirm its audit event contains the correct
|
||||||
|
actor, category, action, target, and change evidence.
|
||||||
|
|
||||||
## Prototype compatibility
|
## Prototype compatibility
|
||||||
|
|
||||||
Treat application data and client payloads as prototype formats until the project owner declares a production-stable format.
|
Treat application data and client payloads as prototype formats until the
|
||||||
|
project owner declares a production-stable format.
|
||||||
|
|
||||||
1. Prefer one current validated schema over compatibility readers, repair branches, dual writes, or fallback parsing for obsolete formats.
|
1. Prefer one current validated schema over compatibility readers, repair
|
||||||
2. Remove incompatible local drafts, saved builds, calibration snapshots, and other derived records through a backed-up migration when the current model cannot represent them safely.
|
branches, dual writes, or fallback parsing for obsolete formats.
|
||||||
3. Preserve authored game data whenever the new model can retain it without guessing.
|
2. Remove incompatible local drafts, saved builds, calibration snapshots, and
|
||||||
4. Follow the database-preservation rules before removing or reshaping persistent data.
|
other derived records through a backed-up migration when the current model
|
||||||
5. Add compatibility support only when the project owner requires it for a production-stable format.
|
cannot represent them safely.
|
||||||
|
3. Preserve authored game data whenever the new model can retain it without
|
||||||
|
guessing.
|
||||||
|
4. Follow the database-preservation rules before removing or reshaping
|
||||||
|
persistent data.
|
||||||
|
5. Add compatibility support only when the project owner requires it for a
|
||||||
|
production-stable format.
|
||||||
|
|
||||||
## Talent mechanic conventions
|
## Talent mechanic conventions
|
||||||
|
|
||||||
Before importing a Talent or changing its mechanic program, read `docs/TALENT_MECHANIC_RULES.md`. Treat the confirmed lifecycle, trigger, cooldown, and targeting rules in that document as authoritative game behavior. Do not mark a mechanic `requires_assumptions` for behavior that the document resolves. When the project owner clarifies another Talent mechanic, record the answer in `docs/TALENT_MECHANIC_RULES.md`.
|
Before importing a Talent or changing its mechanic program, read
|
||||||
|
`docs/TALENT_MECHANIC_RULES.md`. Treat the confirmed lifecycle, trigger,
|
||||||
|
cooldown, and targeting rules in that document as authoritative game behavior.
|
||||||
|
Do not mark a mechanic `requires_assumptions` for behavior that the document
|
||||||
|
resolves. When the project owner clarifies another Talent mechanic, record the
|
||||||
|
answer in `docs/TALENT_MECHANIC_RULES.md`.
|
||||||
|
|
||||||
## Database preservation
|
## Database preservation
|
||||||
|
|
||||||
Development data is valuable. Treat embedded database files as persistent user data, not disposable build artifacts.
|
Treat embedded database files as persistent user data, not disposable build
|
||||||
|
artifacts.
|
||||||
|
|
||||||
Before destructive work on the database, stop the dev server, then create a verified timestamped JSON backup with `npm run db:backup`. Keep backups out of Git (they can contain user records). Prefer additive, repeatable schema migrations over database recreation. Report the backup path when completing database-destructive work.
|
Before destructive work on the database, stop the dev server, then create a
|
||||||
|
verified timestamped JSON backup with `npm run db:backup`. Keep backups out of
|
||||||
|
Git (they can contain user records). Prefer additive, repeatable schema
|
||||||
|
migrations over database recreation. Report the backup path when completing
|
||||||
|
database-destructive work.
|
||||||
|
|
||||||
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target database — it refuses to merge into a populated database.
|
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target
|
||||||
|
database. The command refuses to merge into a populated database.
|
||||||
|
|
||||||
## Development database maintenance
|
## Development database maintenance
|
||||||
|
|
||||||
1. Stop only this project's development-server process tree.
|
1. Stop only this project's development-server process tree.
|
||||||
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
||||||
3. Apply the migration or maintenance operation. Verify schema state, record counts, and critical entities.
|
3. Apply the migration or maintenance operation. Verify schema state, record
|
||||||
4. If an operation fails, preserve logs and the verified backup rather than improvising against the database.
|
counts, and critical entities.
|
||||||
|
4. If an operation fails, preserve logs and the verified backup rather than
|
||||||
|
improvising against the database.
|
||||||
5. Restart the dev server, then confirm a basic health or page check.
|
5. Restart the dev server, then confirm a basic health or page check.
|
||||||
|
|
||||||
Production and staging backups and deployment are defined in `docs/DEPLOYMENT.md`.
|
Production and staging backups and deployment are defined in
|
||||||
|
`docs/DEPLOYMENT.md`.
|
||||||
|
|||||||
+23
-4
@@ -2,7 +2,8 @@
|
|||||||
FROM node:24-bookworm-slim AS dependencies
|
FROM node:24-bookworm-slim AS dependencies
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
RUN npm ci
|
COPY scripts/prepare.mjs ./scripts/prepare.mjs
|
||||||
|
RUN npm ci --ignore-scripts && npm rebuild argon2
|
||||||
|
|
||||||
FROM dependencies AS build
|
FROM dependencies AS build
|
||||||
COPY vite.config.ts tsconfig.json ./
|
COPY vite.config.ts tsconfig.json ./
|
||||||
@@ -13,24 +14,42 @@ RUN npm run build:app
|
|||||||
FROM node:24-bookworm-slim AS runtime-dependencies
|
FROM node:24-bookworm-slim AS runtime-dependencies
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
COPY scripts/prepare.mjs ./scripts/prepare.mjs
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts && npm rebuild argon2 && npm cache clean --force
|
||||||
|
|
||||||
FROM node:24-bookworm-slim AS runtime
|
FROM node:24-bookworm-slim AS runtime
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
ARG INFISICAL_CLI_VERSION=0.43.116
|
||||||
LABEL org.opencontainers.image.title="shd-build"
|
LABEL org.opencontainers.image.title="shd-build"
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PORT=3000 \
|
PORT=3000 \
|
||||||
|
INFISICAL_DOMAIN=https://secrets.jb.codes \
|
||||||
|
INFISICAL_DISABLE_UPDATE_CHECK=true \
|
||||||
TDR_DATABASE_PATH=/app/data/shd-build.db \
|
TDR_DATABASE_PATH=/app/data/shd-build.db \
|
||||||
TDR_AVATAR_STORAGE_PATH=/app/data/avatars \
|
TDR_AVATAR_STORAGE_PATH=/app/data/avatars \
|
||||||
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
|
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl fonts-dejavu-core gosu \
|
||||||
|
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
|
||||||
|
&& case "$TARGETARCH" in \
|
||||||
|
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
|
||||||
|
arm64) infisical_sha256=0111d8e7daa5f3c498a8cc6cb9852bde2c5c21e53c935d4738ddd80c764554cc ;; \
|
||||||
|
*) echo "Unsupported Infisical CLI architecture: $TARGETARCH" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& curl -fsSL "https://github.com/Infisical/cli/releases/download/v${INFISICAL_CLI_VERSION}/cli_${INFISICAL_CLI_VERSION}_linux_${TARGETARCH}.tar.gz" -o /tmp/infisical.tar.gz \
|
||||||
|
&& echo "$infisical_sha256 /tmp/infisical.tar.gz" | sha256sum -c - \
|
||||||
|
&& tar -xzf /tmp/infisical.tar.gz -C /usr/local/bin infisical \
|
||||||
|
&& chmod 755 /usr/local/bin/infisical \
|
||||||
|
&& rm -f /tmp/infisical.tar.gz \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules
|
COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules
|
||||||
COPY --from=build --chown=node:node /app/build ./build
|
COPY --from=build --chown=node:node /app/build ./build
|
||||||
COPY --chown=node:node database ./database
|
COPY --chown=node:node database ./database
|
||||||
COPY --chown=node:node scripts ./scripts
|
COPY --chown=node:node scripts ./scripts
|
||||||
COPY --chown=node:node deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
COPY --chown=node:node deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
||||||
RUN chmod 755 ./deploy/container-entrypoint.sh && mkdir -p /app/data && chown node:node /app/data
|
RUN chmod 755 ./deploy/container-entrypoint.sh ./scripts/runtime-launcher.mjs && mkdir -p /app/data && chown node:node /app/data && chmod 0770 /app/data
|
||||||
USER node
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
ENTRYPOINT ["./deploy/container-entrypoint.sh"]
|
ENTRYPOINT ["./deploy/container-entrypoint.sh"]
|
||||||
|
|||||||
+13
-12
@@ -8,7 +8,7 @@ web
|
|||||||
|
|
||||||
## Users
|
## Users
|
||||||
|
|
||||||
SHD.build serves active *The Division Resurgence* players who need reliable game
|
SHD.build serves active _The Division Resurgence_ players who need reliable game
|
||||||
data, accurate loadout planning, build sharing, and community guides.
|
data, accurate loadout planning, build sharing, and community guides.
|
||||||
|
|
||||||
Registered players can save and share builds, publish guides, participate in
|
Registered players can save and share builds, publish guides, participate in
|
||||||
@@ -21,8 +21,8 @@ administrators manage users, roles, system configuration, and operations.
|
|||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
SHD.build is an independent community intelligence platform for *The Division
|
SHD.build is an independent community intelligence platform for _The Division
|
||||||
Resurgence*. It includes a public game database, calculated loadout builder,
|
Resurgence_. It includes a public game database, calculated loadout builder,
|
||||||
shared builds, guides, news, and community participation.
|
shared builds, guides, news, and community participation.
|
||||||
|
|
||||||
Players should understand game systems, create accurate loadouts, learn from
|
Players should understand game systems, create accurate loadouts, learn from
|
||||||
@@ -41,7 +41,8 @@ Player-facing planning and reference experiences use the same moderated data.
|
|||||||
|
|
||||||
Players use the site to plan characters, compare drops, reproduce in-game
|
Players use the site to plan characters, compare drops, reproduce in-game
|
||||||
equipment, browse community recommendations, and respond to game updates. The
|
equipment, browse community recommendations, and respond to game updates. The
|
||||||
interface must work from mobile devices through high-resolution desktop displays.
|
interface must work from mobile devices through high-resolution desktop
|
||||||
|
displays.
|
||||||
|
|
||||||
The principal public channels are News, Database, Builds, Guides, and Builder.
|
The principal public channels are News, Database, Builds, Guides, and Builder.
|
||||||
Account management includes authentication methods, profile identity, avatars,
|
Account management includes authentication methods, profile identity, avatars,
|
||||||
@@ -58,12 +59,12 @@ connect proposed records to pending records before approval.
|
|||||||
- The application is built with SvelteKit and Svelte.
|
- The application is built with SvelteKit and Svelte.
|
||||||
- Persistent application data uses the embedded `@tursodatabase/database`
|
- Persistent application data uses the embedded `@tursodatabase/database`
|
||||||
package. Turso Cloud is not required.
|
package. Turso Cloud is not required.
|
||||||
- Development, staging, and production use distinct databases. Embedded
|
- Development, staging, and production use distinct databases. Embedded database
|
||||||
database files and uploaded assets are persistent user data and must survive
|
files and uploaded assets are persistent user data and must survive
|
||||||
deployments and additive schema migrations.
|
deployments and additive schema migrations.
|
||||||
- Authentication supports passkeys, emailed magic links, Discord OAuth, and
|
- Authentication supports passkeys, emailed magic links, Discord OAuth, and
|
||||||
optional username/password credentials. An account must always retain at
|
optional username/password credentials. An account must always retain at least
|
||||||
least one usable sign-in method.
|
one usable sign-in method.
|
||||||
- Anonymous builder drafts may use local storage. Database-backed sharing
|
- Anonymous builder drafts may use local storage. Database-backed sharing
|
||||||
requires an account.
|
requires an account.
|
||||||
- Saved builds support private, unlisted, and public visibility.
|
- Saved builds support private, unlisted, and public visibility.
|
||||||
@@ -78,13 +79,13 @@ connect proposed records to pending records before approval.
|
|||||||
- Major data changes preserve contributor-entered data through verified backups
|
- Major data changes preserve contributor-entered data through verified backups
|
||||||
and additive migrations with minimal development-server downtime.
|
and additive migrations with minimal development-server downtime.
|
||||||
- SHD.build is an independent fan site. Ubisoft and the relevant owners retain
|
- SHD.build is an independent fan site. Ubisoft and the relevant owners retain
|
||||||
rights to *Tom Clancy's The Division*, *The Division Resurgence*, and their
|
rights to _Tom Clancy's The Division_, _The Division Resurgence_, and their
|
||||||
associated intellectual property and assets.
|
associated intellectual property and assets.
|
||||||
|
|
||||||
## Brand Commitments
|
## Brand Commitments
|
||||||
|
|
||||||
The product name is `SHD.build`, with `SHD` capitalized. The product serves the
|
The product name is `SHD.build`, with `SHD` capitalized. The product serves the
|
||||||
full *The Division Resurgence* community, including loadout planning.
|
full _The Division Resurgence_ community, including loadout planning.
|
||||||
|
|
||||||
Use restrained in-universe operational language such as agent, ISAC, Oversight,
|
Use restrained in-universe operational language such as agent, ISAC, Oversight,
|
||||||
intelligence, access, transmission, and registry when it improves comprehension.
|
intelligence, access, transmission, and registry when it improves comprehension.
|
||||||
@@ -134,8 +135,8 @@ identity controls must follow their respective provider requirements.
|
|||||||
|
|
||||||
SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation,
|
SHD.build targets WCAG 2.2 AA. Interfaces must support keyboard navigation,
|
||||||
clear focus-visible states, screen-reader semantics, sufficient contrast,
|
clear focus-visible states, screen-reader semantics, sufficient contrast,
|
||||||
reduced-motion preferences, and layouts that work from mobile screens through
|
reduced-motion preferences, and layouts that work from mobile screens through 4K
|
||||||
4K desktop displays.
|
desktop displays.
|
||||||
|
|
||||||
State and meaning cannot depend on color, icons, hover, or motion alone.
|
State and meaning cannot depend on color, icons, hover, or motion alone.
|
||||||
Controls need usable touch targets. User identity must support non-ASCII display
|
Controls need usable touch targets. User identity must support non-ASCII display
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# SHD.build
|
# SHD.build
|
||||||
|
|
||||||
A SvelteKit community application for *The Division Resurgence*. SHD.build
|
A SvelteKit community application for _The Division Resurgence_. SHD.build
|
||||||
combines a public game database, loadout builder, shared builds, guides,
|
combines a public game database, loadout builder, shared builds, guides, account
|
||||||
account management, and the ISAC data-management console.
|
management, and the ISAC data-management console.
|
||||||
|
|
||||||
See `docs/architecture.md` for the implementation model.
|
See `docs/architecture.md` for the implementation model.
|
||||||
|
|
||||||
@@ -17,13 +17,12 @@ Create or update the local database:
|
|||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and
|
The Infisical `dev` environment sets the local file to
|
||||||
set a different location if needed.
|
`./data/shd-build-development.db`.
|
||||||
|
|
||||||
Store custom avatars as metadata-free 256 × 256 WebP files. Set
|
Store custom avatars as metadata-free 256 × 256 WebP files. Set
|
||||||
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production;
|
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it
|
||||||
it defaults to `data/avatars`. The same configuration works on Windows and
|
defaults to `data/avatars`. The same configuration works on Windows and Linux.
|
||||||
Linux.
|
|
||||||
|
|
||||||
Seed the initial local accounts once:
|
Seed the initial local accounts once:
|
||||||
|
|
||||||
@@ -31,9 +30,10 @@ Seed the initial local accounts once:
|
|||||||
npm run db:seed-users
|
npm run db:seed-users
|
||||||
```
|
```
|
||||||
|
|
||||||
The seed creates administrator `Aewyn` and standard user `Quartermaster`.
|
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
||||||
It generates and displays strong temporary passwords once, then stores only
|
generates and displays strong temporary passwords once, then stores only salted
|
||||||
salted Argon2id hashes.
|
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
|
||||||
|
account when no administrator exists.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -41,30 +41,28 @@ salted Argon2id hashes.
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Encrypted environment files
|
## Infisical secrets
|
||||||
|
|
||||||
Keep development, staging, and production settings in the committed, encrypted
|
The committed `.infisical.json` connects the project to the self-hosted
|
||||||
`.env.development`, `.env.staging`, and `.env.production` files. Keep private
|
Infisical instance at `https://secrets.jb.codes`. Authenticate the local CLI
|
||||||
decryption keys only in the ignored `.env.keys` file and back it up in a
|
once. The CLI uses its configured system vault and can fall back to an encrypted
|
||||||
password manager.
|
file vault when the operating-system keyring is unavailable.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run env:decrypt # decrypt all environment files for editing
|
npx infisical login --domain=https://secrets.jb.codes
|
||||||
npm run env:encrypt # encrypt all environment files again
|
npm run dev
|
||||||
npm run env:stage # encrypt and git-add all environment files
|
|
||||||
npm run env:decrypt:development # operate on one environment
|
|
||||||
npm run env:decrypt:staging
|
|
||||||
npm run env:decrypt:production
|
|
||||||
npm run env:sync:staging-credentials # copy shared Discord/SMTP values from development
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository pre-commit hook runs `env:stage` and replaces staged plaintext
|
Local commands use the `dev` environment. Application configuration lives under
|
||||||
environment files with encrypted versions. Git has no pre-add hook. Use
|
`/app`; the scheduler bearer token lives under `/shared/scheduler`. Staging and
|
||||||
`npm run env:stage` instead of directly adding these two files.
|
production commands select the `staging` and `prod` environments explicitly.
|
||||||
|
`npm run check`, `npm test`, and `npm run build` do not contact Infisical.
|
||||||
|
|
||||||
Keep `.env.keys` out of Git and retain a backup. Production deployments receive
|
Staging and production containers authenticate at startup with separate
|
||||||
`DOTENV_PRIVATE_KEY_PRODUCTION` through their secret manager; do not copy the
|
Universal Auth identities. Gitea stores each client secret, while repository
|
||||||
keys file to the server.
|
variables hold the matching non-secret client IDs. The containers fetch current
|
||||||
|
values before migration or startup and stop if authentication or validation
|
||||||
|
fails.
|
||||||
|
|
||||||
## Containers and staging deployment
|
## Containers and staging deployment
|
||||||
|
|
||||||
@@ -73,23 +71,25 @@ The application uses the official SvelteKit Node adapter and a multi-stage
|
|||||||
game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
|
game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
|
||||||
when the container starts; keep them out of the image.
|
when the container starts; keep them out of the image.
|
||||||
|
|
||||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology
|
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging and production
|
||||||
and Gitea Actions deployment. A push to `main` validates the application,
|
Compose topologies. A push to `main` triggers both deployment workflows. Each
|
||||||
builds the candidate image while staging stays online, stops the application
|
workflow validates Infisical access and the candidate image while the current
|
||||||
and scheduler for the database backup and migration, then starts the release.
|
service stays online, then stops its application and scheduler for backup and
|
||||||
|
migration.
|
||||||
|
|
||||||
## Passwordless authentication
|
## Passwordless authentication
|
||||||
|
|
||||||
Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact
|
Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact
|
||||||
public origin for registration and authentication. Set `PASSKEY_RP_ID` to that
|
public origin for registration and authentication. Set `PASSKEY_RP_ID` to that
|
||||||
hostname or a registrable parent domain. Hosted environments use `shd.build`,
|
hostname or a registrable parent domain. Hosted environments use `shd.build`,
|
||||||
which permits credentials on `shd.build` and `dev.shd.build`. Use `localhost`
|
which permits credentials on `shd.build`, `stage.shd.build`, and
|
||||||
for direct local development.
|
`dev.shd.build`. Use `localhost` for direct local development.
|
||||||
|
|
||||||
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`,
|
Magic links require `SMTP_HOST` and `SMTP_FROM`. `SMTP_PORT` defaults to `587`.
|
||||||
`SMTP_PASSWORD`, and `SMTP_FROM`. Use `SMTP_SECURE=true` on port 465. Use
|
Set `SMTP_SECURE=true` for implicit TLS, normally on port 465; the default
|
||||||
`false` on port 587 and upgrade with STARTTLS. An account owner activates an
|
`false` uses STARTTLS. If the SMTP server requires authentication, set both
|
||||||
email identity by following a 15-minute, single-use verification link.
|
`SMTP_USER` and `SMTP_PASSWORD`. An account owner activates an email identity by
|
||||||
|
following a 15-minute, single-use verification link.
|
||||||
|
|
||||||
## Validate
|
## Validate
|
||||||
|
|
||||||
@@ -98,46 +98,6 @@ npm run check
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
[`sv`](https://github.com/sveltejs/cli) creates and configures Svelte projects.
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# recreate this project
|
|
||||||
npx sv@0.16.5 create --template minimal --types ts --install npm .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Install dependencies with `npm install` (or `pnpm install` or `yarn`), then
|
|
||||||
start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Build a production version:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> Deployments may require an [adapter](https://svelte.dev/docs/kit/adapters) for the target environment.
|
|
||||||
|
|
||||||
The shared visual language and dashboard conventions are documented in
|
The shared visual language and dashboard conventions are documented in
|
||||||
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
|
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
|
||||||
|
|
||||||
|
|||||||
+286
-37
@@ -138,14 +138,212 @@ CREATE INDEX IF NOT EXISTS oversight_notification_queue_due_idx ON oversight_not
|
|||||||
CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at);
|
CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at);
|
||||||
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
|
CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
|
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
|
||||||
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('private','unlisted','public')), source_build_id TEXT REFERENCES builds(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||||
CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC);
|
CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC);
|
||||||
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
|
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('guide','news')),
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'private' CHECK(visibility IN ('private','unlisted','public')),
|
||||||
|
published_revision_id TEXT REFERENCES article_revisions(id),
|
||||||
|
pending_revision_id TEXT REFERENCES article_revisions(id),
|
||||||
|
moderation_hold INTEGER NOT NULL DEFAULT 0 CHECK(moderation_hold IN (0,1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
published_at TEXT,
|
||||||
|
deleted_at TEXT,
|
||||||
|
deleted_by TEXT REFERENCES users(id),
|
||||||
|
UNIQUE(kind,slug),
|
||||||
|
CHECK(published_revision_id IS NOT NULL OR visibility='private'),
|
||||||
|
CHECK(deleted_at IS NULL OR visibility='private')
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS articles_owner_kind_updated_idx ON articles(owner_id,kind,updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS articles_public_kind_published_idx ON articles(kind,published_at DESC)
|
||||||
|
WHERE visibility='public' AND deleted_at IS NULL AND published_revision_id IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS article_drafts (
|
||||||
|
article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
deck TEXT NOT NULL DEFAULT '',
|
||||||
|
document_json TEXT NOT NULL CHECK(json_valid(document_json)),
|
||||||
|
tags_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(tags_json)),
|
||||||
|
channel TEXT,
|
||||||
|
cover_image_id TEXT REFERENCES managed_images(id) ON DELETE SET NULL,
|
||||||
|
cover_alt TEXT NOT NULL DEFAULT '',
|
||||||
|
cover_caption TEXT NOT NULL DEFAULT '',
|
||||||
|
version INTEGER NOT NULL DEFAULT 1 CHECK(version>=1),
|
||||||
|
updated_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL REFERENCES articles(id),
|
||||||
|
revision_number INTEGER NOT NULL CHECK(revision_number>=1),
|
||||||
|
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 2 AND 140),
|
||||||
|
deck TEXT NOT NULL DEFAULT '',
|
||||||
|
document_json TEXT NOT NULL CHECK(json_valid(document_json)),
|
||||||
|
plain_text TEXT NOT NULL,
|
||||||
|
channel TEXT,
|
||||||
|
cover_image_id TEXT REFERENCES managed_images(id) ON DELETE SET NULL,
|
||||||
|
cover_alt TEXT NOT NULL DEFAULT '',
|
||||||
|
cover_caption TEXT NOT NULL DEFAULT '',
|
||||||
|
snapshot_hash TEXT NOT NULL CHECK(length(snapshot_hash)=64),
|
||||||
|
created_from_draft_version INTEGER NOT NULL CHECK(created_from_draft_version>=1),
|
||||||
|
created_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(article_id,revision_number)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS article_revisions_article_created_idx ON article_revisions(article_id,created_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_revision_build_embeds (
|
||||||
|
revision_id TEXT NOT NULL REFERENCES article_revisions(id) ON DELETE CASCADE,
|
||||||
|
embed_key TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL CHECK(ordinal>=0),
|
||||||
|
build_id TEXT NOT NULL REFERENCES builds(id),
|
||||||
|
owner_shared INTEGER NOT NULL DEFAULT 0 CHECK(owner_shared IN (0,1)),
|
||||||
|
build_snapshot_json TEXT NOT NULL CHECK(json_valid(build_snapshot_json)),
|
||||||
|
PRIMARY KEY(revision_id,embed_key),
|
||||||
|
UNIQUE(revision_id,ordinal)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS article_revision_build_embeds_build_idx ON article_revision_build_embeds(build_id,revision_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_media (
|
||||||
|
article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
image_id TEXT NOT NULL UNIQUE REFERENCES managed_images(id) ON DELETE CASCADE,
|
||||||
|
uploaded_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(article_id,image_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_tags (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_name TEXT NOT NULL CHECK(group_name IN ('topic','activity','difficulty','specialization','focus','weapon','news_channel')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
source_type TEXT,
|
||||||
|
source_id TEXT,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
||||||
|
UNIQUE(source_type,source_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_revision_tags (
|
||||||
|
revision_id TEXT NOT NULL REFERENCES article_revisions(id) ON DELETE CASCADE,
|
||||||
|
tag_id TEXT NOT NULL REFERENCES article_tags(id),
|
||||||
|
ordinal INTEGER NOT NULL CHECK(ordinal>=0),
|
||||||
|
PRIMARY KEY(revision_id,tag_id),
|
||||||
|
UNIQUE(revision_id,ordinal)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS article_revision_tags_tag_idx ON article_revision_tags(tag_id,revision_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_publication_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL REFERENCES articles(id),
|
||||||
|
revision_id TEXT NOT NULL REFERENCES article_revisions(id),
|
||||||
|
base_published_revision_id TEXT REFERENCES article_revisions(id),
|
||||||
|
requested_visibility TEXT NOT NULL CHECK(requested_visibility IN ('unlisted','public')),
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('scanning','pending_review','approved','rejected','superseded')),
|
||||||
|
scan_id TEXT UNIQUE REFERENCES content_moderation_scans(id),
|
||||||
|
requested_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
decided_by TEXT REFERENCES users(id),
|
||||||
|
decided_at TEXT,
|
||||||
|
decision_reason TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS article_publication_requests_active_idx ON article_publication_requests(article_id)
|
||||||
|
WHERE status IN ('scanning','pending_review');
|
||||||
|
CREATE TABLE IF NOT EXISTS guide_curations (
|
||||||
|
article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
pinned_order INTEGER CHECK(pinned_order IS NULL OR pinned_order>=0),
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0 CHECK(featured IN (0,1)),
|
||||||
|
recommended INTEGER NOT NULL DEFAULT 0 CHECK(recommended IN (0,1)),
|
||||||
|
curated_revision_id TEXT NOT NULL REFERENCES article_revisions(id),
|
||||||
|
curated_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
curated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS guide_curations_discovery_idx ON guide_curations(pinned_order,featured,recommended,curated_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS anonymous_visitors (
|
||||||
|
token_hash TEXT PRIMARY KEY CHECK(length(token_hash)=64),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS anonymous_activity_limits (
|
||||||
|
identity_hash TEXT NOT NULL,
|
||||||
|
action_type TEXT NOT NULL CHECK(action_type IN ('guide_rating','guide_report')),
|
||||||
|
window_started_at TEXT NOT NULL,
|
||||||
|
action_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY(identity_hash,action_type)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS guide_ratings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
revision_id TEXT NOT NULL REFERENCES article_revisions(id),
|
||||||
|
rater_user_id TEXT REFERENCES users(id),
|
||||||
|
anonymous_token_hash TEXT REFERENCES anonymous_visitors(token_hash),
|
||||||
|
value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK((rater_user_id IS NOT NULL AND anonymous_token_hash IS NULL) OR (rater_user_id IS NULL AND anonymous_token_hash IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS guide_ratings_user_idx ON guide_ratings(article_id,rater_user_id) WHERE rater_user_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS guide_ratings_anonymous_idx ON guide_ratings(article_id,anonymous_token_hash) WHERE anonymous_token_hash IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS guide_ratings_article_idx ON guide_ratings(article_id,value);
|
||||||
|
CREATE TABLE IF NOT EXISTS article_search (
|
||||||
|
article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
deck TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO users(id,username,role,created_at) VALUES(
|
||||||
|
'7e2a8a20-9bbd-4f1e-9124-a3fb71f7de55','ubisoft-news-service','user','2026-08-01T00:00:00.000Z'
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO user_profiles(user_id,display_name,is_public,updated_at) VALUES(
|
||||||
|
'7e2a8a20-9bbd-4f1e-9124-a3fb71f7de55','Ubisoft Official News',0,'2026-08-01T00:00:00.000Z'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS external_news_sources (
|
||||||
|
article_id TEXT PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL CHECK(provider IN ('ubisoft')),
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
canonical_url TEXT NOT NULL,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
thumbnail_alt TEXT NOT NULL DEFAULT '',
|
||||||
|
source_published_at TEXT NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL CHECK(length(content_hash)=64),
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
last_synced_at TEXT NOT NULL,
|
||||||
|
UNIQUE(provider,external_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS external_news_sources_last_seen_idx
|
||||||
|
ON external_news_sources(provider,last_seen_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS external_news_sync_state (
|
||||||
|
provider TEXT PRIMARY KEY CHECK(provider IN ('ubisoft')),
|
||||||
|
last_attempt_at TEXT,
|
||||||
|
last_success_at TEXT,
|
||||||
|
next_check_at TEXT NOT NULL,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0 CHECK(consecutive_failures>=0),
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO article_tags(id,group_name,name,slug,source_type,source_id,active) VALUES
|
||||||
|
('guide-builds','topic','Builds','builds','static','builds',1),
|
||||||
|
('guide-pve','activity','PvE','pve','static','pve',1),
|
||||||
|
('guide-pvp','activity','PvP','pvp','static','pvp',1),
|
||||||
|
('guide-dark-zone','activity','Dark Zone','dark-zone','static','dark-zone',1),
|
||||||
|
('guide-conflict','activity','Conflict','conflict','static','conflict',1),
|
||||||
|
('guide-elite','difficulty','Elite','elite','static','elite',1),
|
||||||
|
('guide-heroic','difficulty','Heroic','heroic','static','heroic',1),
|
||||||
|
('guide-shotgun','weapon','Shotgun','shotgun','weapon_category','Shotgun',1),
|
||||||
|
('guide-rifle','weapon','Rifle','rifle','weapon_category','Rifle',1),
|
||||||
|
('guide-mmr','weapon','MMR','mmr','weapon_category','MMR',1),
|
||||||
|
('guide-ar','weapon','AR','ar','weapon_category','AR',1),
|
||||||
|
('guide-lmg','weapon','LMG','lmg','weapon_category','LMG',1),
|
||||||
|
('guide-smg','weapon','SMG','smg','weapon_category','SMG',1),
|
||||||
|
('news-official','news_channel','Official Game News','official-game-news','news_channel','official',1),
|
||||||
|
('news-patch','news_channel','Patch & Data Updates','patch-data-updates','news_channel','patch',1),
|
||||||
|
('news-community','news_channel','Community Reports','community-reports','news_channel','community',1),
|
||||||
|
('news-shd','news_channel','SHD.build Development','shd-build-development','news_channel','shd',1);
|
||||||
CREATE TABLE IF NOT EXISTS comment_threads (
|
CREATE TABLE IF NOT EXISTS comment_threads (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'profile')),
|
target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'news', 'profile', 'game_data')),
|
||||||
target_id TEXT NOT NULL,
|
target_id TEXT NOT NULL,
|
||||||
|
pinned_comment_id TEXT REFERENCES comments(id) ON DELETE SET NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
UNIQUE(target_type, target_id)
|
UNIQUE(target_type, target_id)
|
||||||
);
|
);
|
||||||
@@ -267,6 +465,7 @@ CREATE TABLE IF NOT EXISTS content_moderation_scans (
|
|||||||
author_id TEXT NOT NULL REFERENCES users(id),
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
|
content_hash TEXT NOT NULL CHECK(length(content_hash)>0),
|
||||||
revision_id TEXT REFERENCES moderated_content_revisions(id),
|
revision_id TEXT REFERENCES moderated_content_revisions(id),
|
||||||
|
article_revision_id TEXT REFERENCES article_revisions(id),
|
||||||
policy_version INTEGER NOT NULL CHECK(policy_version>=1),
|
policy_version INTEGER NOT NULL CHECK(policy_version>=1),
|
||||||
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
|
provider TEXT NOT NULL CHECK(length(trim(provider))>0),
|
||||||
model TEXT NOT NULL CHECK(length(trim(model))>0),
|
model TEXT NOT NULL CHECK(length(trim(model))>0),
|
||||||
@@ -281,10 +480,10 @@ CREATE TABLE IF NOT EXISTS content_moderation_scans (
|
|||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
error_code TEXT,
|
error_code TEXT,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
UNIQUE(content_type,content_id,content_hash,policy_version,provider,model),
|
|
||||||
CHECK(
|
CHECK(
|
||||||
(content_type IN ('comment','avatar') AND revision_id IS NULL)
|
(content_type IN ('comment','avatar') AND revision_id IS NULL AND article_revision_id IS NULL)
|
||||||
OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL)
|
OR (content_type='profile_bio' AND revision_id IS NOT NULL AND article_revision_id IS NULL)
|
||||||
|
OR (content_type='guide' AND revision_id IS NULL AND article_revision_id IS NOT NULL)
|
||||||
),
|
),
|
||||||
CHECK(
|
CHECK(
|
||||||
(status='error' AND error_code IS NOT NULL)
|
(status='error' AND error_code IS NOT NULL)
|
||||||
@@ -296,6 +495,13 @@ CREATE INDEX IF NOT EXISTS content_moderation_scans_pending_queue_idx
|
|||||||
WHERE status IN ('review','priority_review');
|
WHERE status IN ('review','priority_review');
|
||||||
CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx
|
CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx
|
||||||
ON content_moderation_scans(content_type,content_id,created_at DESC);
|
ON content_moderation_scans(content_type,content_id,created_at DESC);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_direct_unique_idx
|
||||||
|
ON content_moderation_scans(content_type,content_id,content_hash,policy_version,provider,model)
|
||||||
|
WHERE content_type IN ('comment','avatar');
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_profile_unique_idx
|
||||||
|
ON content_moderation_scans(revision_id,policy_version,provider,model) WHERE content_type='profile_bio';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_guide_unique_idx
|
||||||
|
ON content_moderation_scans(article_revision_id,policy_version,provider,model) WHERE content_type='guide';
|
||||||
CREATE TABLE IF NOT EXISTS content_moderation_reviews (
|
CREATE TABLE IF NOT EXISTS content_moderation_reviews (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
scan_id TEXT NOT NULL REFERENCES content_moderation_scans(id),
|
scan_id TEXT NOT NULL REFERENCES content_moderation_scans(id),
|
||||||
@@ -308,11 +514,14 @@ CREATE INDEX IF NOT EXISTS content_moderation_reviews_scan_created_idx
|
|||||||
ON content_moderation_reviews(scan_id,created_at DESC);
|
ON content_moderation_reviews(scan_id,created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx
|
CREATE INDEX IF NOT EXISTS content_moderation_reviews_reviewer_created_idx
|
||||||
ON content_moderation_reviews(reviewer_id,created_at DESC);
|
ON content_moderation_reviews(reviewer_id,created_at DESC);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_reviews_scan_unique_idx ON content_moderation_reviews(scan_id);
|
||||||
CREATE TABLE IF NOT EXISTS user_content_reports (
|
CREATE TABLE IF NOT EXISTS user_content_reports (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
|
content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')),
|
||||||
content_id TEXT NOT NULL,
|
content_id TEXT NOT NULL,
|
||||||
reporter_id TEXT NOT NULL REFERENCES users(id),
|
reporter_id TEXT REFERENCES users(id),
|
||||||
|
anonymous_token_hash TEXT REFERENCES anonymous_visitors(token_hash),
|
||||||
|
article_revision_id TEXT REFERENCES article_revisions(id),
|
||||||
category TEXT NOT NULL CHECK(category IN (
|
category TEXT NOT NULL CHECK(category IN (
|
||||||
'spam','harassment','hate','sexual','violence','self_harm','privacy','other'
|
'spam','harassment','hate','sexual','violence','self_harm','privacy','other'
|
||||||
)),
|
)),
|
||||||
@@ -322,6 +531,8 @@ CREATE TABLE IF NOT EXISTS user_content_reports (
|
|||||||
reviewed_at TEXT,
|
reviewed_at TEXT,
|
||||||
reviewed_by TEXT REFERENCES users(id),
|
reviewed_by TEXT REFERENCES users(id),
|
||||||
resolution_reason TEXT,
|
resolution_reason TEXT,
|
||||||
|
CHECK((reporter_id IS NOT NULL AND anonymous_token_hash IS NULL) OR (reporter_id IS NULL AND anonymous_token_hash IS NOT NULL)),
|
||||||
|
CHECK((content_type='guide' AND article_revision_id IS NOT NULL) OR (content_type<>'guide' AND article_revision_id IS NULL)),
|
||||||
CHECK(
|
CHECK(
|
||||||
(status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL)
|
(status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL)
|
||||||
OR (status IN ('resolved','dismissed') AND reviewed_at IS NOT NULL AND reviewed_by IS NOT NULL
|
OR (status IN ('resolved','dismissed') AND reviewed_at IS NOT NULL AND reviewed_by IS NOT NULL
|
||||||
@@ -335,7 +546,10 @@ CREATE INDEX IF NOT EXISTS user_content_reports_content_idx
|
|||||||
ON user_content_reports(content_type,content_id,created_at DESC);
|
ON user_content_reports(content_type,content_id,created_at DESC);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx
|
||||||
ON user_content_reports(reporter_id,content_type,content_id)
|
ON user_content_reports(reporter_id,content_type,content_id)
|
||||||
WHERE status='pending';
|
WHERE status='pending' AND reporter_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_anonymous_pending_idx
|
||||||
|
ON user_content_reports(anonymous_token_hash,content_type,content_id)
|
||||||
|
WHERE status='pending' AND anonymous_token_hash IS NOT NULL;
|
||||||
CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id));
|
CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id));
|
||||||
CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
|
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
|
||||||
@@ -453,6 +667,9 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-images
|
|||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-comments-moderate','community.comments.moderate','Review and moderate public build, guide, and agent-profile comments');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-comments-moderate','community.comments.moderate','Review and moderate public build, guide, and agent-profile comments');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-profiles-moderate','community.profiles.moderate','Review and hide public agent-profile biographies');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-profiles-moderate','community.profiles.moderate','Review and hide public agent-profile biographies');
|
||||||
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-community-guides-moderate','community.guides.moderate','Review, hide, and restore public Guide revisions');
|
||||||
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-guides-curate','guides.curate','Pin, feature, and recommend published Guides');
|
||||||
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-news-manage','news.manage','Create, edit, publish, and remove News articles');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-calculation-publish','calculation.publish','Activate combat calculation revisions used by every loadout');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-mechanic-generate','ai.mechanic.generate','Use the OpenAI-assisted mechanic drafting workflow');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-mechanic-generate','ai.mechanic.generate','Use the OpenAI-assisted mechanic drafting workflow');
|
||||||
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-calculation-generate','ai.calculation.generate','Use the OpenAI-assisted combat calculation revision drafting workflow');
|
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-ai-calculation-generate','ai.calculation.generate','Use the OpenAI-assisted combat calculation revision drafting workflow');
|
||||||
@@ -490,6 +707,8 @@ INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moder
|
|||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-avatars-review');
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-avatars-review');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-comments-moderate');
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-comments-moderate');
|
||||||
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-profiles-moderate');
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-profiles-moderate');
|
||||||
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-community-guides-moderate');
|
||||||
|
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-moderator','permission-guides-curate');
|
||||||
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
CREATE TABLE IF NOT EXISTS data_change_proposals (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -499,6 +718,7 @@ CREATE TABLE IF NOT EXISTS data_change_proposals (
|
|||||||
draft_entity_id TEXT,
|
draft_entity_id TEXT,
|
||||||
applied_entity_id TEXT,
|
applied_entity_id TEXT,
|
||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
|
contributor_note TEXT,
|
||||||
payload_json TEXT NOT NULL,
|
payload_json TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
||||||
proposed_by TEXT NOT NULL REFERENCES users(id),
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
||||||
@@ -549,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
|
|||||||
weapon_min REAL,
|
weapon_min REAL,
|
||||||
weapon_max REAL,
|
weapon_max REAL,
|
||||||
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
|
||||||
skill_chip_min REAL,
|
|
||||||
skill_chip_max REAL,
|
|
||||||
cap_value REAL,
|
cap_value REAL,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
|
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
|
||||||
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
|
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
|
||||||
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
|
|
||||||
CHECK(cap_value IS NULL OR cap_value >= 0)
|
CHECK(cap_value IS NULL OR cap_value >= 0)
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
|
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
|
||||||
@@ -686,6 +903,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
|
|||||||
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
|
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
|
||||||
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
|
||||||
targeting_json TEXT,
|
targeting_json TEXT,
|
||||||
|
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
|
||||||
min_state_value REAL,
|
min_state_value REAL,
|
||||||
max_state_value REAL,
|
max_state_value REAL,
|
||||||
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
|
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
|
||||||
@@ -948,14 +1166,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
|
|||||||
CHECK(max_value >= min_value)
|
CHECK(max_value >= min_value)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
|
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
|
||||||
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
|
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
);
|
||||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
|
CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values (
|
||||||
|
stat_attribute_id TEXT NOT NULL,
|
||||||
|
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
||||||
|
min_value REAL NOT NULL,
|
||||||
|
max_value REAL NOT NULL,
|
||||||
|
PRIMARY KEY(stat_attribute_id,quality),
|
||||||
|
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||||
|
CHECK(max_value>=min_value),
|
||||||
|
CHECK(min_value>=0)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
|
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
|
||||||
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
|
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
);
|
||||||
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
|
CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values (
|
||||||
|
stat_attribute_id TEXT NOT NULL,
|
||||||
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
|
min_value REAL NOT NULL,
|
||||||
|
max_value REAL NOT NULL,
|
||||||
|
PRIMARY KEY(stat_attribute_id,quality),
|
||||||
|
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||||
|
CHECK(max_value>=min_value),
|
||||||
|
CHECK(min_value>=0)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS skill_chip_talents (
|
CREATE TABLE IF NOT EXISTS skill_chip_talents (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1012,19 +1246,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
|
|||||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
|
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
|
||||||
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
|
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
|
||||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
|
||||||
weapon_mod_id TEXT NOT NULL,
|
|
||||||
stat_attribute_id TEXT NOT NULL,
|
stat_attribute_id TEXT NOT NULL,
|
||||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
min_value REAL NOT NULL,
|
level_1_min REAL NOT NULL,
|
||||||
max_value REAL NOT NULL,
|
level_1_max REAL NOT NULL,
|
||||||
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
|
level_20_min REAL NOT NULL,
|
||||||
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
level_20_max REAL NOT NULL,
|
||||||
CHECK(max_value>=min_value)
|
PRIMARY KEY(stat_attribute_id,quality),
|
||||||
|
FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||||
|
CHECK(level_1_max>=level_1_min),
|
||||||
|
CHECK(level_20_max>=level_20_min),
|
||||||
|
CHECK(level_20_min>=level_1_min),
|
||||||
|
CHECK(level_20_max>=level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
|
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1072,19 +1308,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
|
|||||||
CHECK(primary_level_20_max>=primary_level_1_max)
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
|
||||||
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
|
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
|
||||||
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
|
|
||||||
PRIMARY KEY(gear_mod_id,stat_attribute_id)
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
|
||||||
gear_mod_id TEXT NOT NULL,
|
|
||||||
stat_attribute_id TEXT NOT NULL,
|
stat_attribute_id TEXT NOT NULL,
|
||||||
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
|
||||||
min_value REAL NOT NULL,
|
level_1_min REAL NOT NULL,
|
||||||
max_value REAL NOT NULL,
|
level_1_max REAL NOT NULL,
|
||||||
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
|
level_20_min REAL NOT NULL,
|
||||||
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
|
level_20_max REAL NOT NULL,
|
||||||
CHECK(max_value>=min_value)
|
PRIMARY KEY(stat_attribute_id,quality),
|
||||||
|
FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
|
||||||
|
CHECK(level_1_max>=level_1_min),
|
||||||
|
CHECK(level_20_max>=level_20_min),
|
||||||
|
CHECK(level_20_min>=level_1_min),
|
||||||
|
CHECK(level_20_max>=level_1_max)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS specialization_progression_config (
|
CREATE TABLE IF NOT EXISTS specialization_progression_config (
|
||||||
id TEXT PRIMARY KEY CHECK(id='default'),
|
id TEXT PRIMARY KEY CHECK(id='default'),
|
||||||
@@ -1184,6 +1422,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
|
|||||||
CHECK(charge_policy<>'none' OR charge_count=1)
|
CHECK(charge_policy<>'none' OR charge_count=1)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
CREATE INDEX IF NOT EXISTS specialization_abilities_specialization_idx ON specialization_abilities(specialization_id,name);
|
||||||
|
CREATE TABLE IF NOT EXISTS specialization_ability_skill_chip_links (
|
||||||
|
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||||
|
compatible_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY(ability_id,compatible_ability_id),
|
||||||
|
CHECK(ability_id<compatible_ability_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS specialization_ability_skill_chip_links_compatible_idx
|
||||||
|
ON specialization_ability_skill_chip_links(compatible_ability_id,ability_id);
|
||||||
|
-- db-migrate.mjs installs insert/update triggers for active same-specialization
|
||||||
|
-- endpoints and one-pair-per-ability enforcement. Trigger bodies cannot live in
|
||||||
|
-- this semicolon-delimited schema file.
|
||||||
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
CREATE TABLE IF NOT EXISTS specialization_ability_effects (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
|
||||||
@@ -1408,7 +1657,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator
|
|||||||
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
|
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
|
||||||
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
|
||||||
CREATE TABLE IF NOT EXISTS game_data_icons (
|
CREATE TABLE IF NOT EXISTS game_data_icons (
|
||||||
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
|
||||||
entity_id TEXT NOT NULL,
|
entity_id TEXT NOT NULL,
|
||||||
storage_key TEXT NOT NULL UNIQUE,
|
storage_key TEXT NOT NULL UNIQUE,
|
||||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
||||||
|
|||||||
@@ -7,23 +7,45 @@ if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
|
|||||||
echo "Usage: bootstrap-production.sh <bootstrap-bundle.json> [deployment-directory]" >&2
|
echo "Usage: bootstrap-production.sh <bootstrap-bundle.json> [deployment-directory]" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
bundle_path=$(readlink -f "$bundle_path")
|
||||||
|
|
||||||
compose_file="$deployment_directory/compose.production.yml"
|
compose_file="$deployment_directory/compose.production.yml"
|
||||||
deploy_environment="$deployment_directory/.env.deploy"
|
deploy_environment="$deployment_directory/.env.deploy"
|
||||||
|
volume_bundle_path=/app/data/.production-bootstrap-import.json
|
||||||
|
remove_volume_bundle=false
|
||||||
compose() {
|
compose() {
|
||||||
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
}
|
}
|
||||||
restart_services() {
|
cleanup() {
|
||||||
compose up -d --no-build app scheduler
|
status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$remove_volume_bundle" = true ]; then
|
||||||
|
compose run --rm --no-deps --entrypoint rm --user root app -f "$volume_bundle_path"
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then status=1; fi
|
||||||
|
exit "$status"
|
||||||
}
|
}
|
||||||
trap restart_services EXIT
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
echo "Stopping production before the one-time database bootstrap."
|
echo "Stopping production before the one-time database bootstrap."
|
||||||
compose stop scheduler app || true
|
compose stop scheduler app
|
||||||
|
compose create app >/dev/null
|
||||||
|
app_container=$(compose ps --all --quiet app)
|
||||||
|
if [ -z "$app_container" ]; then
|
||||||
|
echo "The stopped production app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker cp "$bundle_path" "$app_container:$volume_bundle_path"
|
||||||
|
remove_volume_bundle=true
|
||||||
|
compose run --rm --no-deps --entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node "$1" && chmod 600 "$1"' sh "$volume_bundle_path"
|
||||||
compose run --rm --no-deps \
|
compose run --rm --no-deps \
|
||||||
-e ALLOW_PRODUCTION_BOOTSTRAP=true \
|
-e ALLOW_PRODUCTION_BOOTSTRAP=true \
|
||||||
-v "$bundle_path:/import/production-bootstrap.json:ro" \
|
|
||||||
app node scripts/environment-bootstrap-import.mjs \
|
app node scripts/environment-bootstrap-import.mjs \
|
||||||
/import/production-bootstrap.json --target production --confirm-empty-target-replace
|
"$volume_bundle_path" --target production --confirm-empty-target-replace
|
||||||
|
|
||||||
echo "Production bootstrap completed."
|
echo "Production bootstrap completed."
|
||||||
|
|||||||
@@ -7,23 +7,45 @@ if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
|
|||||||
echo "Usage: bootstrap-staging.sh <bootstrap-bundle.json> [deployment-directory]" >&2
|
echo "Usage: bootstrap-staging.sh <bootstrap-bundle.json> [deployment-directory]" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
bundle_path=$(readlink -f "$bundle_path")
|
||||||
|
|
||||||
compose_file="$deployment_directory/compose.staging.yml"
|
compose_file="$deployment_directory/compose.staging.yml"
|
||||||
deploy_environment="$deployment_directory/.env.deploy"
|
deploy_environment="$deployment_directory/.env.deploy"
|
||||||
|
volume_bundle_path=/app/data/.staging-bootstrap-import.json
|
||||||
|
remove_volume_bundle=false
|
||||||
compose() {
|
compose() {
|
||||||
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
||||||
}
|
}
|
||||||
restart_services() {
|
cleanup() {
|
||||||
compose up -d --no-build app scheduler
|
status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$remove_volume_bundle" = true ]; then
|
||||||
|
compose run --rm --no-deps --entrypoint rm --user root app -f "$volume_bundle_path"
|
||||||
|
fi
|
||||||
|
if ! compose up -d --no-build app scheduler; then status=1; fi
|
||||||
|
exit "$status"
|
||||||
}
|
}
|
||||||
trap restart_services EXIT
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
echo "Stopping staging before the one-time database bootstrap."
|
echo "Stopping staging before the one-time database bootstrap."
|
||||||
compose stop scheduler app || true
|
compose stop scheduler app
|
||||||
|
compose create app >/dev/null
|
||||||
|
app_container=$(compose ps --all --quiet app)
|
||||||
|
if [ -z "$app_container" ]; then
|
||||||
|
echo "The stopped staging app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker cp "$bundle_path" "$app_container:$volume_bundle_path"
|
||||||
|
remove_volume_bundle=true
|
||||||
|
compose run --rm --no-deps --entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node "$1" && chmod 600 "$1"' sh "$volume_bundle_path"
|
||||||
compose run --rm --no-deps \
|
compose run --rm --no-deps \
|
||||||
-e ALLOW_STAGING_BOOTSTRAP=true \
|
-e ALLOW_STAGING_BOOTSTRAP=true \
|
||||||
-v "$bundle_path:/import/staging-bootstrap.json:ro" \
|
|
||||||
app node scripts/stage-bootstrap-import.mjs \
|
app node scripts/stage-bootstrap-import.mjs \
|
||||||
/import/staging-bootstrap.json --confirm-empty-staging-replace
|
"$volume_bundle_path" --confirm-empty-staging-replace
|
||||||
|
|
||||||
echo "Staging bootstrap completed."
|
echo "Staging bootstrap completed."
|
||||||
|
|||||||
@@ -5,25 +5,29 @@ services:
|
|||||||
image: ${SHD_IMAGE:-shd-build-production:local}
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
env_file:
|
|
||||||
- .env.production.runtime
|
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
PORT: 3000
|
INFISICAL_ENVIRONMENT: prod
|
||||||
BODY_SIZE_LIMIT: 6M
|
INFISICAL_RUNTIME_SCOPE: app
|
||||||
SKIP_DEFAULT_USERS: "true"
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
TDR_DATABASE_PATH: /app/data/shd-build-production.db
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
|
||||||
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
APP_ORIGIN: https://shd.build
|
SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
|
||||||
ORIGIN: https://shd.build
|
|
||||||
PASSKEY_RP_ID: shd.build
|
|
||||||
DISCORD_REDIRECT_URI: https://shd.build/auth/discord/callback
|
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000"
|
- '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- shd_production_data:/app/data
|
- shd_production_data:/app/data
|
||||||
|
secrets:
|
||||||
|
- source: infisical_app_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_app_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
@@ -42,10 +46,23 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
command:
|
command:
|
||||||
- scheduler
|
- scheduler
|
||||||
env_file:
|
|
||||||
- .env.production.runtime
|
|
||||||
environment:
|
environment:
|
||||||
SCHEDULER_URL: http://app:3000/api/internal/scheduler
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
|
INFISICAL_ENVIRONMENT: prod
|
||||||
|
INFISICAL_RUNTIME_SCOPE: scheduler
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
|
secrets:
|
||||||
|
- source: infisical_scheduler_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_scheduler_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -54,3 +71,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
shd_production_data:
|
shd_production_data:
|
||||||
name: shd-build-production-data
|
name: shd-build-production-data
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
infisical_app_client_id:
|
||||||
|
file: .infisical.app.client-id
|
||||||
|
infisical_app_client_secret:
|
||||||
|
file: .infisical.app.client-secret
|
||||||
|
infisical_scheduler_client_id:
|
||||||
|
file: .infisical.scheduler.client-id
|
||||||
|
infisical_scheduler_client_secret:
|
||||||
|
file: .infisical.scheduler.client-secret
|
||||||
|
|||||||
+44
-13
@@ -5,21 +5,29 @@ services:
|
|||||||
image: ${SHD_IMAGE:-shd-build-stage:local}
|
image: ${SHD_IMAGE:-shd-build-stage:local}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
env_file:
|
|
||||||
- .env.staging.runtime
|
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
PORT: 3000
|
INFISICAL_ENVIRONMENT: staging
|
||||||
BODY_SIZE_LIMIT: 6M
|
INFISICAL_RUNTIME_SCOPE: app
|
||||||
SKIP_DEFAULT_USERS: "true"
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
TDR_DATABASE_PATH: /app/data/shd-build-staging.db
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
|
||||||
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
|
||||||
|
SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:${SHD_STAGE_PORT:-5569}:3000"
|
- '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- shd_stage_data:/app/data
|
- shd_stage_data:/app/data
|
||||||
|
secrets:
|
||||||
|
- source: infisical_app_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_app_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
@@ -38,10 +46,23 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
command:
|
command:
|
||||||
- scheduler
|
- scheduler
|
||||||
env_file:
|
|
||||||
- .env.staging.runtime
|
|
||||||
environment:
|
environment:
|
||||||
SCHEDULER_URL: http://app:3000/api/internal/scheduler
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
|
INFISICAL_ENVIRONMENT: staging
|
||||||
|
INFISICAL_RUNTIME_SCOPE: scheduler
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
|
||||||
|
secrets:
|
||||||
|
- source: infisical_scheduler_client_id
|
||||||
|
target: infisical_client_id
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
|
- source: infisical_scheduler_client_secret
|
||||||
|
target: infisical_client_secret
|
||||||
|
uid: '0'
|
||||||
|
gid: '0'
|
||||||
|
mode: 0400
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -50,3 +71,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
shd_stage_data:
|
shd_stage_data:
|
||||||
name: shd-build-stage-data
|
name: shd-build-stage-data
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
infisical_app_client_id:
|
||||||
|
file: .infisical.app.client-id
|
||||||
|
infisical_app_client_secret:
|
||||||
|
file: .infisical.app.client-secret
|
||||||
|
infisical_scheduler_client_id:
|
||||||
|
file: .infisical.scheduler.client-id
|
||||||
|
infisical_scheduler_client_secret:
|
||||||
|
file: .infisical.scheduler.client-secret
|
||||||
|
|||||||
@@ -1,13 +1,118 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
if [ "${1:-}" = "serve" ]; then
|
project_id=${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID is required}
|
||||||
node scripts/db-migrate.mjs
|
environment=${INFISICAL_ENVIRONMENT:?INFISICAL_ENVIRONMENT is required}
|
||||||
exec node build
|
runtime_scope=${INFISICAL_RUNTIME_SCOPE:?INFISICAL_RUNTIME_SCOPE is required}
|
||||||
|
client_id_file=${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE:?INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required}
|
||||||
|
client_secret_file=${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE:?INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE is required}
|
||||||
|
build_number=${SHD_BUILD_NUMBER:-0}
|
||||||
|
commit_sha=${SHD_COMMIT_SHA:-}
|
||||||
|
secret_file=
|
||||||
|
shared_secret_file=
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$secret_file" ]; then rm -f "$secret_file"; fi
|
||||||
|
if [ -n "$shared_secret_file" ]; then rm -f "$shared_secret_file"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
if [ ! -r "$client_id_file" ] || [ ! -r "$client_secret_file" ]; then
|
||||||
|
echo "Infisical Universal Auth credential files are not readable." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${1:-}" = "scheduler" ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
exec node scripts/scheduler-client.mjs
|
runtime_uid=$(id -u shd-runtime)
|
||||||
|
for credential_file in "$client_id_file" "$client_secret_file"; do
|
||||||
|
credential_uid=$(stat -c '%u' "$credential_file")
|
||||||
|
credential_mode=$(stat -c '%a' "$credential_file")
|
||||||
|
if [ "$credential_uid" -eq "$runtime_uid" ] || [ $((credential_mode % 100)) -ne 0 ]; then
|
||||||
|
echo "Infisical credential files must not be readable by the application runtime user." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$runtime_scope" = app ] && ! { [ "${1:-}" = node ] && [ "${2:-}" = scripts/validate-runtime-environment.mjs ]; }; then
|
||||||
|
chgrp -R node /app/data
|
||||||
|
chmod -R g+rwX /app/data
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
client_id=$(cat "$client_id_file")
|
||||||
|
client_secret=$(cat "$client_secret_file")
|
||||||
|
if [ -z "$client_id" ] || [ -z "$client_secret" ]; then
|
||||||
|
echo "Infisical Universal Auth credential files must not be empty." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset INFISICAL_TOKEN INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN TOKEN
|
||||||
|
access_token=$(
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$client_id" \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$client_secret" \
|
||||||
|
infisical login --method=universal-auth --silent --plain
|
||||||
|
)
|
||||||
|
unset client_id client_secret
|
||||||
|
|
||||||
|
if [ -z "$access_token" ]; then
|
||||||
|
echo "Infisical Universal Auth returned an empty access token." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
secret_file=$(mktemp /tmp/shd-infisical-app.XXXXXX)
|
||||||
|
|
||||||
|
case "$runtime_scope" in
|
||||||
|
app)
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
|
||||||
|
--projectId="$project_id" \
|
||||||
|
--env="$environment" \
|
||||||
|
--path=/app \
|
||||||
|
--format=json \
|
||||||
|
--output-file="$secret_file" \
|
||||||
|
--silent
|
||||||
|
shared_secret_file=$(mktemp /tmp/shd-infisical-shared.XXXXXX)
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
|
||||||
|
--projectId="$project_id" \
|
||||||
|
--env="$environment" \
|
||||||
|
--path=/shared/scheduler \
|
||||||
|
--format=json \
|
||||||
|
--output-file="$shared_secret_file" \
|
||||||
|
--silent
|
||||||
|
;;
|
||||||
|
scheduler)
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
|
||||||
|
--projectId="$project_id" \
|
||||||
|
--env="$environment" \
|
||||||
|
--path=/shared/scheduler \
|
||||||
|
--format=json \
|
||||||
|
--output-file="$secret_file" \
|
||||||
|
--silent
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported INFISICAL_RUNTIME_SCOPE: $runtime_scope" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
unset access_token \
|
||||||
|
INFISICAL_TOKEN \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET
|
||||||
|
|
||||||
|
if [ "$runtime_scope" = app ]; then
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown shd-runtime:node "$secret_file" "$shared_secret_file"
|
||||||
|
exec gosu shd-runtime node scripts/runtime-launcher.mjs app "$environment" "$build_number" "$commit_sha" "$secret_file" "$shared_secret_file" -- "$@"
|
||||||
|
fi
|
||||||
|
exec node scripts/runtime-launcher.mjs app "$environment" "$build_number" "$commit_sha" "$secret_file" "$shared_secret_file" -- "$@"
|
||||||
|
fi
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
chown shd-runtime:node "$secret_file"
|
||||||
|
exec gosu shd-runtime node scripts/runtime-launcher.mjs scheduler "$environment" "$build_number" "$commit_sha" "$secret_file" -- "$@"
|
||||||
|
fi
|
||||||
|
exec node scripts/runtime-launcher.mjs scheduler "$environment" "$build_number" "$commit_sha" "$secret_file" -- "$@"
|
||||||
|
|||||||
+131
-25
@@ -4,48 +4,154 @@ set -eu
|
|||||||
deployment_directory=${1:-/opt/shd-build-production}
|
deployment_directory=${1:-/opt/shd-build-production}
|
||||||
source_directory=${2:-}
|
source_directory=${2:-}
|
||||||
candidate_image=${3:-}
|
candidate_image=${3:-}
|
||||||
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then
|
candidate_directory=${4:-}
|
||||||
echo "Usage: deploy-production.sh <deployment-directory> <source-directory> <candidate-image>" >&2
|
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ] || [ ! -f "$candidate_directory/compose.production.yml" ] || [ ! -f "$candidate_directory/.env.deploy" ]; then
|
||||||
|
echo "Usage: deploy-production.sh <deployment-directory> <source-directory> <candidate-image> <candidate-directory>" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
compose_file="$deployment_directory/compose.production.yml"
|
case "$candidate_directory" in
|
||||||
deploy_environment="$deployment_directory/.env.deploy"
|
"$deployment_directory"/candidate-*) ;;
|
||||||
|
*) echo "Candidate directory must be a candidate-* child of the deployment directory." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
candidate_compose_file="$candidate_directory/compose.production.yml"
|
||||||
|
candidate_environment="$candidate_directory/.env.deploy"
|
||||||
|
canonical_compose_file="$deployment_directory/compose.production.yml"
|
||||||
|
canonical_environment="$deployment_directory/.env.deploy"
|
||||||
|
previous_directory="$deployment_directory/.previous-release"
|
||||||
|
healthy_marker="$deployment_directory/.deployment-healthy"
|
||||||
|
release_files="compose.production.yml .env.deploy .infisical.app.client-id .infisical.app.client-secret .infisical.scheduler.client-id .infisical.scheduler.client-secret deploy-production.sh bootstrap-production.sh sync-production-isac-to-staging.sh"
|
||||||
|
rollback_required=false
|
||||||
|
candidate_promoted=false
|
||||||
|
had_previous_release=false
|
||||||
|
|
||||||
compose() {
|
candidate_compose() {
|
||||||
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
docker compose --env-file "$candidate_environment" -f "$candidate_compose_file" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canonical_compose() {
|
||||||
|
docker compose --env-file "$canonical_environment" -f "$canonical_compose_file" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_is_healthy() {
|
||||||
|
health_environment=$1
|
||||||
|
health_compose_file=$2
|
||||||
|
health_attempt=0
|
||||||
|
while [ "$health_attempt" -lt 30 ]; do
|
||||||
|
health_app_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q app)
|
||||||
|
health_scheduler_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q scheduler)
|
||||||
|
health_app_status=missing
|
||||||
|
health_scheduler_status=missing
|
||||||
|
if [ -n "$health_app_container" ]; then
|
||||||
|
health_app_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$health_app_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ -n "$health_scheduler_container" ]; then
|
||||||
|
health_scheduler_status=$(docker inspect --format '{{.State.Status}}' "$health_scheduler_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ "$health_app_status" = healthy ] && [ "$health_scheduler_status" = running ]; then return 0; fi
|
||||||
|
if [ "$health_app_status" = unhealthy ] || [ "$health_app_status" = exited ] || [ "$health_app_status" = dead ] || [ "$health_scheduler_status" = exited ] || [ "$health_scheduler_status" = dead ]; then return 1; fi
|
||||||
|
health_attempt=$((health_attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_release() {
|
||||||
|
copy_source=$1
|
||||||
|
copy_target=$2
|
||||||
|
copy_status=0
|
||||||
|
for release_file in $release_files; do
|
||||||
|
if [ -f "$copy_source/$release_file" ]; then
|
||||||
|
copy_temporary=$(mktemp "$copy_target/.release.XXXXXX") || return 1
|
||||||
|
if cp -p "$copy_source/$release_file" "$copy_temporary"; then
|
||||||
|
mv "$copy_temporary" "$copy_target/$release_file" || copy_status=1
|
||||||
|
else
|
||||||
|
rm -f "$copy_temporary"
|
||||||
|
copy_status=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$copy_target/$release_file" || copy_status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$copy_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_candidate() {
|
||||||
|
case "$candidate_directory" in
|
||||||
|
"$deployment_directory"/candidate-*) rm -rf "$candidate_directory" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
rollback_exit_status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$rollback_required" = true ]; then
|
||||||
|
echo "Restoring the previous healthy production release." >&2
|
||||||
|
restore_status=0
|
||||||
|
candidate_compose stop scheduler app >/dev/null 2>&1 || restore_status=1
|
||||||
|
if [ "$candidate_promoted" = true ]; then copy_release "$previous_directory" "$deployment_directory" || restore_status=1; fi
|
||||||
|
if [ "$had_previous_release" = true ]; then
|
||||||
|
canonical_compose up -d --no-build app scheduler || restore_status=1
|
||||||
|
if [ "$restore_status" -eq 0 ] && ! release_is_healthy "$canonical_environment" "$canonical_compose_file"; then restore_status=1; fi
|
||||||
|
else
|
||||||
|
echo "No previous production release is available." >&2
|
||||||
|
restore_status=1
|
||||||
|
fi
|
||||||
|
if [ "$restore_status" -ne 0 ]; then
|
||||||
|
echo "The previous production release could not be restored." >&2
|
||||||
|
if [ "$rollback_exit_status" -eq 0 ]; then rollback_exit_status=1; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cleanup_candidate
|
||||||
|
exit "$rollback_exit_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap rollback EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
echo "Building the candidate image while production remains online."
|
echo "Building the candidate image while production remains online."
|
||||||
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
|
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
|
||||||
|
|
||||||
|
echo "Validating Infisical access and the candidate runtime configuration."
|
||||||
|
candidate_compose run --rm --no-deps app node scripts/validate-runtime-environment.mjs app
|
||||||
|
candidate_compose run --rm --no-deps scheduler node scripts/validate-runtime-environment.mjs scheduler
|
||||||
|
|
||||||
|
rm -rf "$previous_directory"
|
||||||
|
mkdir -p "$previous_directory"
|
||||||
|
if [ -f "$canonical_compose_file" ] && [ -f "$canonical_environment" ]; then
|
||||||
|
had_previous_release=true
|
||||||
|
copy_release "$deployment_directory" "$previous_directory"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Stopping only SHD production processes before accessing the embedded database."
|
echo "Stopping only SHD production processes before accessing the embedded database."
|
||||||
compose stop scheduler app || true
|
rollback_required=true
|
||||||
|
candidate_compose stop scheduler app
|
||||||
|
|
||||||
echo "Creating a timestamped production backup when the database exists."
|
echo "Creating a timestamped production backup when the database exists."
|
||||||
compose run --rm --no-deps app sh -c \
|
candidate_compose run --rm --no-deps app sh -c \
|
||||||
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No production database exists yet; skipping backup."; fi'
|
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No production database exists yet; skipping backup."; fi'
|
||||||
|
|
||||||
echo "Applying additive schema migrations."
|
echo "Applying additive schema migrations."
|
||||||
compose run --rm --no-deps app node scripts/db-migrate.mjs
|
candidate_compose run --rm --no-deps app node scripts/db-migrate.mjs
|
||||||
|
|
||||||
|
candidate_promoted=true
|
||||||
|
copy_release "$candidate_directory" "$deployment_directory"
|
||||||
|
|
||||||
echo "Starting production."
|
echo "Starting production."
|
||||||
compose up -d --no-build --remove-orphans app scheduler
|
canonical_compose up -d --no-build --remove-orphans app scheduler
|
||||||
|
|
||||||
container_id=$(compose ps -q app)
|
if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
|
||||||
attempt=0
|
echo "SHD production deployment is healthy."
|
||||||
while [ "$attempt" -lt 30 ]; do
|
rollback_required=false
|
||||||
status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")
|
umask 077
|
||||||
if [ "$status" = "healthy" ]; then
|
: > "$healthy_marker"
|
||||||
echo "SHD production deployment is healthy."
|
cleanup_candidate
|
||||||
exit 0
|
trap - EXIT HUP INT TERM
|
||||||
fi
|
exit 0
|
||||||
if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then
|
fi
|
||||||
break
|
|
||||||
fi
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "SHD production failed its health check."
|
echo "SHD production failed its health check."
|
||||||
compose logs --tail=150 app
|
canonical_compose logs --tail=150 app scheduler
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
+131
-25
@@ -4,48 +4,154 @@ set -eu
|
|||||||
deployment_directory=${1:-/opt/shd-build-stage}
|
deployment_directory=${1:-/opt/shd-build-stage}
|
||||||
source_directory=${2:-}
|
source_directory=${2:-}
|
||||||
candidate_image=${3:-}
|
candidate_image=${3:-}
|
||||||
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then
|
candidate_directory=${4:-}
|
||||||
echo "Usage: deploy-staging.sh <deployment-directory> <source-directory> <candidate-image>" >&2
|
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ] || [ ! -f "$candidate_directory/compose.staging.yml" ] || [ ! -f "$candidate_directory/.env.deploy" ]; then
|
||||||
|
echo "Usage: deploy-staging.sh <deployment-directory> <source-directory> <candidate-image> <candidate-directory>" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
compose_file="$deployment_directory/compose.staging.yml"
|
case "$candidate_directory" in
|
||||||
deploy_environment="$deployment_directory/.env.deploy"
|
"$deployment_directory"/candidate-*) ;;
|
||||||
|
*) echo "Candidate directory must be a candidate-* child of the deployment directory." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
candidate_compose_file="$candidate_directory/compose.staging.yml"
|
||||||
|
candidate_environment="$candidate_directory/.env.deploy"
|
||||||
|
canonical_compose_file="$deployment_directory/compose.staging.yml"
|
||||||
|
canonical_environment="$deployment_directory/.env.deploy"
|
||||||
|
previous_directory="$deployment_directory/.previous-release"
|
||||||
|
healthy_marker="$deployment_directory/.deployment-healthy"
|
||||||
|
release_files="compose.staging.yml .env.deploy .infisical.app.client-id .infisical.app.client-secret .infisical.scheduler.client-id .infisical.scheduler.client-secret deploy-staging.sh bootstrap-staging.sh"
|
||||||
|
rollback_required=false
|
||||||
|
candidate_promoted=false
|
||||||
|
had_previous_release=false
|
||||||
|
|
||||||
compose() {
|
candidate_compose() {
|
||||||
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
|
docker compose --env-file "$candidate_environment" -f "$candidate_compose_file" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canonical_compose() {
|
||||||
|
docker compose --env-file "$canonical_environment" -f "$canonical_compose_file" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
release_is_healthy() {
|
||||||
|
health_environment=$1
|
||||||
|
health_compose_file=$2
|
||||||
|
health_attempt=0
|
||||||
|
while [ "$health_attempt" -lt 30 ]; do
|
||||||
|
health_app_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q app)
|
||||||
|
health_scheduler_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q scheduler)
|
||||||
|
health_app_status=missing
|
||||||
|
health_scheduler_status=missing
|
||||||
|
if [ -n "$health_app_container" ]; then
|
||||||
|
health_app_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$health_app_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ -n "$health_scheduler_container" ]; then
|
||||||
|
health_scheduler_status=$(docker inspect --format '{{.State.Status}}' "$health_scheduler_container" 2>/dev/null || echo missing)
|
||||||
|
fi
|
||||||
|
if [ "$health_app_status" = healthy ] && [ "$health_scheduler_status" = running ]; then return 0; fi
|
||||||
|
if [ "$health_app_status" = unhealthy ] || [ "$health_app_status" = exited ] || [ "$health_app_status" = dead ] || [ "$health_scheduler_status" = exited ] || [ "$health_scheduler_status" = dead ]; then return 1; fi
|
||||||
|
health_attempt=$((health_attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_release() {
|
||||||
|
copy_source=$1
|
||||||
|
copy_target=$2
|
||||||
|
copy_status=0
|
||||||
|
for release_file in $release_files; do
|
||||||
|
if [ -f "$copy_source/$release_file" ]; then
|
||||||
|
copy_temporary=$(mktemp "$copy_target/.release.XXXXXX") || return 1
|
||||||
|
if cp -p "$copy_source/$release_file" "$copy_temporary"; then
|
||||||
|
mv "$copy_temporary" "$copy_target/$release_file" || copy_status=1
|
||||||
|
else
|
||||||
|
rm -f "$copy_temporary"
|
||||||
|
copy_status=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$copy_target/$release_file" || copy_status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$copy_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_candidate() {
|
||||||
|
case "$candidate_directory" in
|
||||||
|
"$deployment_directory"/candidate-*) rm -rf "$candidate_directory" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
rollback_exit_status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
set +e
|
||||||
|
if [ "$rollback_required" = true ]; then
|
||||||
|
echo "Restoring the previous healthy staging release." >&2
|
||||||
|
restore_status=0
|
||||||
|
candidate_compose stop scheduler app >/dev/null 2>&1 || restore_status=1
|
||||||
|
if [ "$candidate_promoted" = true ]; then copy_release "$previous_directory" "$deployment_directory" || restore_status=1; fi
|
||||||
|
if [ "$had_previous_release" = true ]; then
|
||||||
|
canonical_compose up -d --no-build app scheduler || restore_status=1
|
||||||
|
if [ "$restore_status" -eq 0 ] && ! release_is_healthy "$canonical_environment" "$canonical_compose_file"; then restore_status=1; fi
|
||||||
|
else
|
||||||
|
echo "No previous staging release is available." >&2
|
||||||
|
restore_status=1
|
||||||
|
fi
|
||||||
|
if [ "$restore_status" -ne 0 ]; then
|
||||||
|
echo "The previous staging release could not be restored." >&2
|
||||||
|
if [ "$rollback_exit_status" -eq 0 ]; then rollback_exit_status=1; fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cleanup_candidate
|
||||||
|
exit "$rollback_exit_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap rollback EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
echo "Building the candidate image while the current staging service remains online."
|
echo "Building the candidate image while the current staging service remains online."
|
||||||
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
|
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
|
||||||
|
|
||||||
|
echo "Validating Infisical access and the candidate runtime configuration."
|
||||||
|
candidate_compose run --rm --no-deps app node scripts/validate-runtime-environment.mjs app
|
||||||
|
candidate_compose run --rm --no-deps scheduler node scripts/validate-runtime-environment.mjs scheduler
|
||||||
|
|
||||||
|
rm -rf "$previous_directory"
|
||||||
|
mkdir -p "$previous_directory"
|
||||||
|
if [ -f "$canonical_compose_file" ] && [ -f "$canonical_environment" ]; then
|
||||||
|
had_previous_release=true
|
||||||
|
copy_release "$deployment_directory" "$previous_directory"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Stopping SHD staging processes before accessing the embedded database."
|
echo "Stopping SHD staging processes before accessing the embedded database."
|
||||||
compose stop scheduler app || true
|
rollback_required=true
|
||||||
|
candidate_compose stop scheduler app
|
||||||
|
|
||||||
echo "Creating a timestamped database backup when a staging database exists."
|
echo "Creating a timestamped database backup when a staging database exists."
|
||||||
compose run --rm --no-deps app sh -c \
|
candidate_compose run --rm --no-deps app sh -c \
|
||||||
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No staging database exists yet; skipping backup."; fi'
|
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No staging database exists yet; skipping backup."; fi'
|
||||||
|
|
||||||
echo "Applying additive schema migrations."
|
echo "Applying additive schema migrations."
|
||||||
compose run --rm --no-deps app node scripts/db-migrate.mjs
|
candidate_compose run --rm --no-deps app node scripts/db-migrate.mjs
|
||||||
|
|
||||||
|
candidate_promoted=true
|
||||||
|
copy_release "$candidate_directory" "$deployment_directory"
|
||||||
|
|
||||||
echo "Starting the application and notification scheduler."
|
echo "Starting the application and notification scheduler."
|
||||||
compose up -d --no-build --remove-orphans app scheduler
|
canonical_compose up -d --no-build --remove-orphans app scheduler
|
||||||
|
|
||||||
container_id=$(compose ps -q app)
|
if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
|
||||||
attempt=0
|
echo "SHD staging deployment is healthy."
|
||||||
while [ "$attempt" -lt 30 ]; do
|
rollback_required=false
|
||||||
status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")
|
umask 077
|
||||||
if [ "$status" = "healthy" ]; then
|
: > "$healthy_marker"
|
||||||
echo "SHD staging deployment is healthy."
|
cleanup_candidate
|
||||||
exit 0
|
trap - EXIT HUP INT TERM
|
||||||
fi
|
exit 0
|
||||||
if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then
|
fi
|
||||||
break
|
|
||||||
fi
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "SHD staging failed its health check."
|
echo "SHD staging failed its health check."
|
||||||
compose logs --tail=150 app
|
canonical_compose logs --tail=150 app scheduler
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(import.meta.dirname, '..');
|
||||||
|
const schedulerToken = 'scheduler-token-with-at-least-32-characters';
|
||||||
|
|
||||||
|
const infisicalStub = `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
case "\${1:-}" in
|
||||||
|
login)
|
||||||
|
printf '%s\n' "$@" > "$STUB_LOGIN_LOG"
|
||||||
|
if [ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" != "$STUB_EXPECTED_CLIENT_ID" ] || \
|
||||||
|
[ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" != "$STUB_EXPECTED_CLIENT_SECRET" ]; then
|
||||||
|
printf '%s\n' 'credentials=incorrect' >> "$STUB_LOGIN_LOG"
|
||||||
|
exit 26
|
||||||
|
fi
|
||||||
|
printf '%s\n' 'credentials=ok' >> "$STUB_LOGIN_LOG"
|
||||||
|
case "\${STUB_LOGIN_RESULT:-success}" in
|
||||||
|
fail) exit 23 ;;
|
||||||
|
empty) exit 0 ;;
|
||||||
|
*) printf '%s\n' stub-access-token ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
export)
|
||||||
|
[ "\${INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN:-}" = stub-access-token ] || exit 27
|
||||||
|
secret_path=
|
||||||
|
output_file=
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--path=*) secret_path=\${argument#--path=} ;;
|
||||||
|
--output-file=*) output_file=\${argument#--output-file=} ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$secret_path" ] && [ -n "$output_file" ] || exit 28
|
||||||
|
printf '%s|%s\n' "$secret_path" "$output_file" >> "$STUB_EXPORT_LOG"
|
||||||
|
case "$secret_path" in
|
||||||
|
/app) cp "$STUB_APP_EXPORT" "$output_file" ;;
|
||||||
|
/shared/scheduler) cp "$STUB_SHARED_EXPORT" "$output_file" ;;
|
||||||
|
*) exit 29 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) exit 25 ;;
|
||||||
|
esac
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentCapture = `#!/bin/sh
|
||||||
|
env > "$CAPTURE_ENV_PATH"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const gosuStub = `#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
[ "\${1:-}" = shd-runtime ] || exit 30
|
||||||
|
printf '%s\n' 'target=shd-runtime' > "$STUB_GOSU_LOG"
|
||||||
|
shift
|
||||||
|
exec "$@"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const idStub = `#!/bin/sh
|
||||||
|
if [ "\${1:-}" = -u ] && [ "\${2:-}" = shd-runtime ]; then
|
||||||
|
printf '%s\n' 10001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /usr/bin/id "$@"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ownershipStub = `#!/bin/sh
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('container runtime startup', () => {
|
||||||
|
let temporaryRoot: string;
|
||||||
|
let clientIdFile: string;
|
||||||
|
let clientSecretFile: string;
|
||||||
|
let loginLog: string;
|
||||||
|
let exportLog: string;
|
||||||
|
let gosuLog: string;
|
||||||
|
let capturedEnvironment: string;
|
||||||
|
let appExport: string;
|
||||||
|
let sharedExport: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
temporaryRoot = await mkdtemp(join(tmpdir(), 'shd-runtime-start-'));
|
||||||
|
const deployDirectory = join(temporaryRoot, 'deploy');
|
||||||
|
const scriptsDirectory = join(temporaryRoot, 'scripts');
|
||||||
|
await mkdir(deployDirectory);
|
||||||
|
await mkdir(scriptsDirectory);
|
||||||
|
await Promise.all([
|
||||||
|
copyFile(
|
||||||
|
join(repositoryRoot, 'deploy/container-entrypoint.sh'),
|
||||||
|
join(deployDirectory, 'container-entrypoint.sh'),
|
||||||
|
),
|
||||||
|
copyFile(
|
||||||
|
join(repositoryRoot, 'scripts/runtime-launcher.mjs'),
|
||||||
|
join(scriptsDirectory, 'runtime-launcher.mjs'),
|
||||||
|
),
|
||||||
|
copyFile(
|
||||||
|
join(repositoryRoot, 'scripts/runtime-environment.js'),
|
||||||
|
join(scriptsDirectory, 'runtime-environment.js'),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
clientIdFile = join(temporaryRoot, 'client-id');
|
||||||
|
clientSecretFile = join(temporaryRoot, 'client-secret');
|
||||||
|
loginLog = join(temporaryRoot, 'login.log');
|
||||||
|
exportLog = join(temporaryRoot, 'export.log');
|
||||||
|
gosuLog = join(temporaryRoot, 'gosu.log');
|
||||||
|
capturedEnvironment = join(temporaryRoot, 'child.env');
|
||||||
|
appExport = join(temporaryRoot, 'app-export.json');
|
||||||
|
sharedExport = join(temporaryRoot, 'shared-export.json');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(clientIdFile, 'universal-client-id\n', { mode: 0o600 }),
|
||||||
|
writeFile(clientSecretFile, 'universal-client-secret\n', { mode: 0o600 }),
|
||||||
|
writeFile(join(temporaryRoot, 'infisical'), infisicalStub, {
|
||||||
|
mode: 0o755,
|
||||||
|
}),
|
||||||
|
writeFile(
|
||||||
|
join(temporaryRoot, 'capture-environment'),
|
||||||
|
environmentCapture,
|
||||||
|
{
|
||||||
|
mode: 0o755,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
writeFile(join(temporaryRoot, 'gosu'), gosuStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'id'), idStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'chgrp'), ownershipStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'chmod'), ownershipStub, { mode: 0o755 }),
|
||||||
|
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
||||||
|
writeExport(appExport, [
|
||||||
|
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||||
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||||
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||||
|
]),
|
||||||
|
writeExport(sharedExport, [
|
||||||
|
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function runtimeEnvironment(
|
||||||
|
scope: 'app' | 'scheduler',
|
||||||
|
): Record<string, string> {
|
||||||
|
return {
|
||||||
|
PATH: `${temporaryRoot}:${process.env.PATH ?? ''}`,
|
||||||
|
INFISICAL_PROJECT_ID: 'project-id',
|
||||||
|
INFISICAL_ENVIRONMENT: 'staging',
|
||||||
|
INFISICAL_RUNTIME_SCOPE: scope,
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: clientIdFile,
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: clientSecretFile,
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'bootstrap-client-id',
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'bootstrap-client-secret',
|
||||||
|
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN: 'bootstrap-access-token',
|
||||||
|
INFISICAL_TOKEN: 'bootstrap-token',
|
||||||
|
TOKEN: 'generic-bootstrap-token',
|
||||||
|
STUB_EXPECTED_CLIENT_ID: 'universal-client-id',
|
||||||
|
STUB_EXPECTED_CLIENT_SECRET: 'universal-client-secret',
|
||||||
|
STUB_LOGIN_LOG: loginLog,
|
||||||
|
STUB_EXPORT_LOG: exportLog,
|
||||||
|
STUB_GOSU_LOG: gosuLog,
|
||||||
|
STUB_APP_EXPORT: appExport,
|
||||||
|
STUB_SHARED_EXPORT: sharedExport,
|
||||||
|
CAPTURE_ENV_PATH: capturedEnvironment,
|
||||||
|
SHD_BUILD_NUMBER: '42',
|
||||||
|
SHD_COMMIT_SHA: 'abc123',
|
||||||
|
APP_ORIGIN: 'https://parent.invalid',
|
||||||
|
SCHEDULER_URL: 'https://parent.invalid/scheduler',
|
||||||
|
BASH_ENV: '',
|
||||||
|
CDPATH: temporaryRoot,
|
||||||
|
ENV: '',
|
||||||
|
IFS: 'hostile-ifs',
|
||||||
|
LD_LIBRARY_PATH: temporaryRoot,
|
||||||
|
LD_PRELOAD: '',
|
||||||
|
NODE_OPTIONS: '--no-warnings',
|
||||||
|
NODE_PATH: temporaryRoot,
|
||||||
|
PYTHONPATH: temporaryRoot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(environment: Record<string, string>) {
|
||||||
|
return spawnSync(
|
||||||
|
'/bin/sh',
|
||||||
|
['./deploy/container-entrypoint.sh', 'capture-environment'],
|
||||||
|
{
|
||||||
|
cwd: temporaryRoot,
|
||||||
|
env: environment,
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('exports app and shared secrets into a scrubbed app child environment', async () => {
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(await exportPaths()).toEqual(['/app', '/shared/scheduler']);
|
||||||
|
expect(await readFile(loginLog, 'utf8')).toBe(
|
||||||
|
'login\n--method=universal-auth\n--silent\n--plain\ncredentials=ok\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const childEnvironment = parseEnvironment(
|
||||||
|
await readFile(capturedEnvironment, 'utf8'),
|
||||||
|
);
|
||||||
|
expect(childEnvironment).toMatchObject({
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||||
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||||
|
ORIGIN: 'https://stage.shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SCHEDULER_TOKEN: schedulerToken,
|
||||||
|
SHD_BUILD_NUMBER: '42',
|
||||||
|
SHD_COMMIT_SHA: 'abc123',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
});
|
||||||
|
for (const name of removedEnvironmentNames) {
|
||||||
|
expect(childEnvironment).not.toHaveProperty(name);
|
||||||
|
}
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
await expectRootPrivilegeDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports only shared secrets into the scheduler child environment', async () => {
|
||||||
|
const result = start(runtimeEnvironment('scheduler'));
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(await exportPaths()).toEqual(['/shared/scheduler']);
|
||||||
|
expect(
|
||||||
|
parseEnvironment(await readFile(capturedEnvironment, 'utf8')),
|
||||||
|
).toMatchObject({
|
||||||
|
SCHEDULER_TOKEN: schedulerToken,
|
||||||
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||||
|
});
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
await expectRootPrivilegeDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on an unknown secret before launching the child', async () => {
|
||||||
|
await writeExport(appExport, [
|
||||||
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||||
|
{ key: 'UNKNOWN_SECRET', value: 'must-not-reach-child' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Infisical secret UNKNOWN_SECRET is not allowed in this runtime scope.',
|
||||||
|
);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when a credential file setting is missing', () => {
|
||||||
|
const environment = runtimeEnvironment('app');
|
||||||
|
delete environment.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE;
|
||||||
|
|
||||||
|
const result = start(environment);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required',
|
||||||
|
);
|
||||||
|
expect(existsSync(exportLog)).toBe(false);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when either credential is empty', async () => {
|
||||||
|
for (const credentialFile of [clientIdFile, clientSecretFile]) {
|
||||||
|
await writeFile(credentialFile, '');
|
||||||
|
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain('credential files must not be empty');
|
||||||
|
expect(existsSync(exportLog)).toBe(false);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
await writeFile(
|
||||||
|
credentialFile,
|
||||||
|
credentialFile === clientIdFile
|
||||||
|
? 'universal-client-id\n'
|
||||||
|
: 'universal-client-secret\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['fail', 'empty'])(
|
||||||
|
'fails closed when login returns %s',
|
||||||
|
(loginResult) => {
|
||||||
|
const result = start({
|
||||||
|
...runtimeEnvironment('app'),
|
||||||
|
STUB_LOGIN_RESULT: loginResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(existsSync(exportLog)).toBe(false);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('fails closed on a malformed export and removes every secret file', async () => {
|
||||||
|
await writeFile(appExport, '{not-json');
|
||||||
|
|
||||||
|
const result = start(runtimeEnvironment('app'));
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Infisical returned an unreadable secret export.',
|
||||||
|
);
|
||||||
|
expect(existsSync(capturedEnvironment)).toBe(false);
|
||||||
|
await expectSecretFilesRemoved();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function exportPaths() {
|
||||||
|
return (await exportRecords()).map(([path]) => path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSecretFilesRemoved() {
|
||||||
|
for (const [, file] of await exportRecords()) {
|
||||||
|
expect(existsSync(file)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportRecords() {
|
||||||
|
return (await readFile(exportLog, 'utf8'))
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.split('|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRootPrivilegeDrop() {
|
||||||
|
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||||
|
expect(await readFile(gosuLog, 'utf8')).toBe('target=shd-runtime\n');
|
||||||
|
} else {
|
||||||
|
expect(existsSync(gosuLog)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedEnvironmentNames = [
|
||||||
|
'BASH_ENV',
|
||||||
|
'CDPATH',
|
||||||
|
'ENV',
|
||||||
|
'IFS',
|
||||||
|
'INFISICAL_ENVIRONMENT',
|
||||||
|
'INFISICAL_PROJECT_ID',
|
||||||
|
'INFISICAL_RUNTIME_SCOPE',
|
||||||
|
'INFISICAL_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
||||||
|
'LD_LIBRARY_PATH',
|
||||||
|
'LD_PRELOAD',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'NODE_PATH',
|
||||||
|
'PYTHONPATH',
|
||||||
|
'TOKEN',
|
||||||
|
];
|
||||||
|
|
||||||
|
function writeExport(
|
||||||
|
file: string,
|
||||||
|
entries: Array<{ key: string; value: string }>,
|
||||||
|
) {
|
||||||
|
return writeFile(file, `${JSON.stringify(entries)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvironment(value: string) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const separator = line.indexOf('=');
|
||||||
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,11 @@ production_environment="$production_directory/.env.deploy"
|
|||||||
staging_compose_file="$staging_directory/compose.staging.yml"
|
staging_compose_file="$staging_directory/compose.staging.yml"
|
||||||
staging_environment="$staging_directory/.env.deploy"
|
staging_environment="$staging_directory/.env.deploy"
|
||||||
snapshot_name="isac-production-$(date -u +%Y-%m-%dT%H-%M-%SZ).json"
|
snapshot_name="isac-production-$(date -u +%Y-%m-%dT%H-%M-%SZ).json"
|
||||||
snapshot_path="/tmp/$snapshot_name"
|
snapshot_path=$(mktemp "/tmp/$snapshot_name.XXXXXX")
|
||||||
|
production_snapshot_path="/app/data/exports/$snapshot_name"
|
||||||
|
staging_snapshot_path="/app/data/$snapshot_name"
|
||||||
|
remove_production_snapshot=false
|
||||||
|
remove_staging_snapshot=false
|
||||||
|
|
||||||
production_compose() {
|
production_compose() {
|
||||||
docker compose --env-file "$production_environment" -f "$production_compose_file" "$@"
|
docker compose --env-file "$production_environment" -f "$production_compose_file" "$@"
|
||||||
@@ -22,22 +26,54 @@ production_compose() {
|
|||||||
staging_compose() {
|
staging_compose() {
|
||||||
docker compose --env-file "$staging_environment" -f "$staging_compose_file" "$@"
|
docker compose --env-file "$staging_environment" -f "$staging_compose_file" "$@"
|
||||||
}
|
}
|
||||||
restart_services() {
|
cleanup() {
|
||||||
production_compose up -d --no-build app scheduler || true
|
exit_status=$?
|
||||||
staging_compose up -d --no-build app scheduler || true
|
trap - EXIT
|
||||||
rm -f "$snapshot_path"
|
if [ "$remove_production_snapshot" = true ]; then
|
||||||
|
production_compose run --rm --no-deps app rm -f "$production_snapshot_path" || true
|
||||||
|
fi
|
||||||
|
if [ "$remove_staging_snapshot" = true ]; then
|
||||||
|
staging_compose run --rm --no-deps app rm -f "$staging_snapshot_path" || true
|
||||||
|
fi
|
||||||
|
rm -f "$snapshot_path" || true
|
||||||
|
if ! production_compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
if ! staging_compose up -d --no-build app scheduler; then
|
||||||
|
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
|
||||||
|
fi
|
||||||
|
exit "$exit_status"
|
||||||
}
|
}
|
||||||
trap restart_services EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
echo "Stopping production writers for a consistent canonical export."
|
echo "Stopping production writers for a consistent canonical export."
|
||||||
production_compose stop scheduler app
|
production_compose stop scheduler app
|
||||||
production_compose run --rm --no-deps -v "/tmp:/export" app \
|
production_app_container=$(production_compose ps --all --quiet app)
|
||||||
node scripts/isac-data-export.mjs "/export/$snapshot_name"
|
if [ -z "$production_app_container" ]; then
|
||||||
|
echo "The stopped production app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_production_snapshot=true
|
||||||
|
production_compose run --rm --no-deps app \
|
||||||
|
node scripts/isac-data-export.mjs "$production_snapshot_path"
|
||||||
|
docker cp "$production_app_container:$production_snapshot_path" "$snapshot_path"
|
||||||
|
production_compose run --rm --no-deps app rm -f "$production_snapshot_path"
|
||||||
|
remove_production_snapshot=false
|
||||||
production_compose up -d --no-build app scheduler
|
production_compose up -d --no-build app scheduler
|
||||||
|
|
||||||
echo "Stopping staging writers before its automatic full and ISAC safety backups."
|
echo "Stopping staging writers before its automatic full and ISAC safety backups."
|
||||||
staging_compose stop scheduler app
|
staging_compose stop scheduler app
|
||||||
staging_compose run --rm --no-deps -v "/tmp:/import:ro" app \
|
staging_app_container=$(staging_compose ps --all --quiet app)
|
||||||
node scripts/isac-data-import.mjs "/import/$snapshot_name" --confirm-replace-isac-data
|
if [ -z "$staging_app_container" ]; then
|
||||||
|
echo "The stopped staging app container could not be found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remove_staging_snapshot=true
|
||||||
|
docker cp "$snapshot_path" "$staging_app_container:$staging_snapshot_path"
|
||||||
|
staging_compose run --rm --no-deps app \
|
||||||
|
node scripts/isac-data-import.mjs "$staging_snapshot_path" --confirm-replace-isac-data
|
||||||
|
staging_compose run --rm --no-deps app rm -f "$staging_snapshot_path"
|
||||||
|
remove_staging_snapshot=false
|
||||||
|
rm -f "$snapshot_path"
|
||||||
|
|
||||||
echo "Production ISAC data was safely synchronized into staging."
|
echo "Production ISAC data was safely synchronized into staging."
|
||||||
|
|||||||
+44
-27
@@ -1,14 +1,20 @@
|
|||||||
# Community content moderation
|
# Community content moderation
|
||||||
|
|
||||||
SHD.build applies one moderation lifecycle to public comments, profile
|
SHD.build applies one moderation lifecycle to public comments, profile
|
||||||
biographies, custom avatars, guides, and build descriptions. The shared server
|
biographies, custom avatars, and Guides. The shared server service lives in
|
||||||
service lives in `src/lib/server/community-moderation.ts`. Content save actions
|
`src/lib/server/community-moderation.ts`. Content save actions must use that
|
||||||
must use that service before publication.
|
service before publication.
|
||||||
|
|
||||||
|
Public database records also host one moderated discussion thread. The thread
|
||||||
|
uses the existing `comment` content type and accepts only visible comments from
|
||||||
|
authenticated Agents. Community moderators may pin one visible comment as the
|
||||||
|
record's Community Note. Pinning and unpinning append `community_note.pinned`
|
||||||
|
and `community_note.unpinned` audit events; a pinned comment remains ordinary
|
||||||
|
community text and follows the same removal rules as every other comment.
|
||||||
|
|
||||||
## Publication decisions
|
## Publication decisions
|
||||||
|
|
||||||
The moderation service combines local rules with the configured provider
|
The moderation service combines local rules with the configured provider result:
|
||||||
result:
|
|
||||||
|
|
||||||
- Clean content publishes without human review when automatic publication is
|
- Clean content publishes without human review when automatic publication is
|
||||||
enabled.
|
enabled.
|
||||||
@@ -18,8 +24,8 @@ result:
|
|||||||
enabled.
|
enabled.
|
||||||
- An allow term can suppress the matching local term rule. It cannot override a
|
- An allow term can suppress the matching local term rule. It cannot override a
|
||||||
provider flag or threshold.
|
provider flag or threshold.
|
||||||
- A user report sends visible content to Oversight. One report does not hide
|
- A user report sends visible content to Oversight. One report does not hide the
|
||||||
the content because reports can be mistaken or abusive.
|
content because reports can be mistaken or abusive.
|
||||||
|
|
||||||
The service records every automated scan in `content_moderation_scans`.
|
The service records every automated scan in `content_moderation_scans`.
|
||||||
Automated scans do not write to the human ISAC audit log. Human policy changes
|
Automated scans do not write to the human ISAC audit log. Human policy changes
|
||||||
@@ -33,13 +39,20 @@ Comments use an explicit status:
|
|||||||
- `pending_review`: hidden while Oversight decides
|
- `pending_review`: hidden while Oversight decides
|
||||||
- `removed`: unavailable after author removal or an Oversight decision
|
- `removed`: unavailable after author removal or an Oversight decision
|
||||||
|
|
||||||
Long-form content uses immutable pending revisions. Profile and guide edits that
|
Long-form content uses immutable pending revisions. Profile biographies keep
|
||||||
need review go into `moderated_content_revisions`; the public row keeps its last
|
text candidates in `moderated_content_revisions`. Guides use the complete
|
||||||
approved text. Approval copies the reviewed revision into the public row.
|
structured snapshot in `article_revisions`, including title, deck, document,
|
||||||
Rejection preserves the revision and review evidence without publishing it.
|
tags, media references, and Build snapshots. The public article keeps its last
|
||||||
|
approved revision while another revision waits for review. Rejection retains the
|
||||||
|
submitted revision and evidence without publishing it.
|
||||||
|
|
||||||
Custom avatar uploads remain active after a clean image scan. A flagged image
|
News articles do not use automated content moderation. The `news.manage`
|
||||||
or fail-closed scan error creates an `avatar_reviews` item and is unavailable
|
permission gates News creation, editing, publication, and deletion. Comments on
|
||||||
|
News articles remain community content and use the standard comment moderation
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
Custom avatar uploads remain active after a clean image scan. A flagged image or
|
||||||
|
fail-closed scan error creates an `avatar_reviews` item and is unavailable
|
||||||
through the public avatar route until approval. An Agent report also creates a
|
through the public avatar route until approval. An Agent report also creates a
|
||||||
review item, but leaves an already-published avatar visible while Oversight
|
review item, but leaves an already-published avatar visible while Oversight
|
||||||
decides.
|
decides.
|
||||||
@@ -69,8 +82,8 @@ applicable, and records a specific ISAC audit event with before and after
|
|||||||
evidence. Unsaved changes in other categories remain local drafts.
|
evidence. Unsaved changes in other categories remain local drafts.
|
||||||
|
|
||||||
Site Controls requires `site.settings.manage`. Community Oversight handles text
|
Site Controls requires `site.settings.manage`. Community Oversight handles text
|
||||||
decisions through the existing comment and profile permissions. Avatar
|
decisions through the existing comment and profile permissions. Avatar Oversight
|
||||||
Oversight handles custom images through `avatars.review`.
|
handles custom images through `avatars.review`.
|
||||||
|
|
||||||
Moderators must record a reason when they approve, reject, remove, restore,
|
Moderators must record a reason when they approve, reject, remove, restore,
|
||||||
dismiss, or resolve a queued item. The action must append an audit event after
|
dismiss, or resolve a queued item. The action must append an audit event after
|
||||||
@@ -78,10 +91,14 @@ the content and review records change.
|
|||||||
|
|
||||||
## User reports
|
## User reports
|
||||||
|
|
||||||
Authenticated Agents can report visible content they do not own. The report
|
Authenticated Agents can report visible comments, biographies, and avatars they
|
||||||
control must identify the content type and stable content ID, validate that the
|
do not own. Registered and anonymous visitors can report visible Guides. A Guide
|
||||||
content remains public, and create a pending `user_content_reports` row.
|
report records the exact published `article_revision_id`; a decision cannot hide
|
||||||
Duplicate pending reports from the same Agent for the same content are rejected.
|
a newer revision. Anonymous reports use a random HTTP-only browser token whose
|
||||||
|
domain-separated SHA-256 hash supplies duplicate suppression. The server applies
|
||||||
|
rate limits and requests Turnstile verification after repeated anonymous
|
||||||
|
activity. SHD.build does not use device fingerprinting or retain a raw IP
|
||||||
|
address as the reporter identity.
|
||||||
|
|
||||||
A reviewer either dismisses the report and keeps the content or resolves the
|
A reviewer either dismisses the report and keeps the content or resolves the
|
||||||
report through a moderation action. Report details must not copy authentication
|
report through a moderation action. Report details must not copy authentication
|
||||||
@@ -89,8 +106,7 @@ secrets or unnecessary personal information into the audit log.
|
|||||||
|
|
||||||
## Adding a public UGC field
|
## Adding a public UGC field
|
||||||
|
|
||||||
Guide authoring, build descriptions, and later public text fields must complete
|
Guide authoring and later public text fields must complete this checklist:
|
||||||
this checklist:
|
|
||||||
|
|
||||||
1. Normalize and validate the submitted text before scanning.
|
1. Normalize and validate the submitted text before scanning.
|
||||||
2. Call `moderateCommunityText` with a supported content type.
|
2. Call `moderateCommunityText` with a supported content type.
|
||||||
@@ -98,14 +114,15 @@ this checklist:
|
|||||||
4. Publish only an `approved` result.
|
4. Publish only an `approved` result.
|
||||||
5. Store held long-form text as an immutable revision so the last approved
|
5. Store held long-form text as an immutable revision so the last approved
|
||||||
version remains public.
|
version remains public.
|
||||||
6. Call `recordCommunityModerationScan` with the stable content ID, author, hash,
|
6. Call `recordCommunityModerationScan` with the stable content ID, exact
|
||||||
policy version, provider evidence, and local-rule matches.
|
article revision, author, hash, policy version, provider evidence, and
|
||||||
|
local-rule matches.
|
||||||
7. Add the content type to the Community Oversight queue and permission checks.
|
7. Add the content type to the Community Oversight queue and permission checks.
|
||||||
8. Add the shared report control to the public rendering.
|
8. Add the shared report control to the public rendering.
|
||||||
9. Delay follower, owner, or reply notifications until the content becomes
|
9. Delay follower, owner, or reply notifications until the content becomes
|
||||||
public.
|
public.
|
||||||
10. Add approval, rejection, report dismissal, author notification, and audit
|
10. Add approval, rejection, report dismissal, author notification, and audit
|
||||||
tests.
|
tests.
|
||||||
11. Confirm every public query selects approved content explicitly.
|
11. Confirm every public query selects approved content explicitly.
|
||||||
|
|
||||||
Do not add a route-specific word filter, provider client, threshold table, or
|
Do not add a route-specific word filter, provider client, threshold table, or
|
||||||
@@ -127,9 +144,9 @@ option, update the legal copy before sending user content under that setting.
|
|||||||
|
|
||||||
Threshold changes increment the policy version. Stored scans retain their model
|
Threshold changes increment the policy version. Stored scans retain their model
|
||||||
and policy version so reviewers can interpret older decisions. Test threshold
|
and policy version so reviewers can interpret older decisions. Test threshold
|
||||||
changes against representative Division terminology before activation,
|
changes against representative Division terminology before activation, including
|
||||||
including weapon names, damage descriptions, kills, status effects, and build
|
weapon names, damage descriptions, kills, status effects, and build strategy
|
||||||
strategy language.
|
language.
|
||||||
|
|
||||||
Database migrations that add moderation states or revision storage follow the
|
Database migrations that add moderation states or revision storage follow the
|
||||||
repository database-preservation procedure: stop this project's development
|
repository database-preservation procedure: stop this project's development
|
||||||
|
|||||||
+230
-103
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
SHD.build runs in a Node 24 container through the official SvelteKit Node
|
SHD.build runs in a Node 24 container through the official SvelteKit Node
|
||||||
adapter. The image includes application code, migration and notification
|
adapter. The image includes application code, migration and notification
|
||||||
scripts, and native runtime dependencies. Environment files, databases,
|
scripts, the Infisical CLI, and native runtime dependencies. Databases, backups,
|
||||||
backups, and user uploads remain outside the image.
|
user uploads, and Universal Auth bootstrap credentials remain outside the image.
|
||||||
|
|
||||||
## Persistent data
|
## Persistent data
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ mounted at `/app/data`. It contains:
|
|||||||
- uploaded game-data icons under `game-icons/`.
|
- uploaded game-data icons under `game-icons/`.
|
||||||
|
|
||||||
Keep this volume during routine deployments. `docker compose down` without
|
Keep this volume during routine deployments. `docker compose down` without
|
||||||
`--volumes` preserves it. Before a manual backup or restore, stop the `app`
|
`--volumes` preserves it. Before a manual backup or restore, stop the `app` and
|
||||||
and `scheduler` services because both access the embedded database.
|
`scheduler` services because both access the embedded database.
|
||||||
|
|
||||||
## Staging environment
|
## Staging environment
|
||||||
|
|
||||||
@@ -29,25 +29,74 @@ Staging defaults to:
|
|||||||
- Passkey relying-party ID: `shd.build`
|
- Passkey relying-party ID: `shd.build`
|
||||||
- Adapter canonical origin: `https://stage.shd.build`
|
- Adapter canonical origin: `https://stage.shd.build`
|
||||||
|
|
||||||
Edit its encrypted configuration locally:
|
Guide ratings and reports use Cloudflare Turnstile after repeated anonymous
|
||||||
|
activity. Configure both values under `/app` in each Infisical environment:
|
||||||
|
|
||||||
```sh
|
```text
|
||||||
npm run env:decrypt:staging
|
TURNSTILE_SITE_KEY=<public site key>
|
||||||
# Fill in Discord and SMTP values.
|
TURNSTILE_SECRET_KEY=<server verification secret>
|
||||||
npm run env:encrypt:staging
|
```
|
||||||
|
|
||||||
|
Register the exact staging and production hostnames in the Turnstile widget. The
|
||||||
|
browser receives only `TURNSTILE_SITE_KEY`. The server sends a challenge token
|
||||||
|
and the request address to Cloudflare for verification; it never exposes
|
||||||
|
`TURNSTILE_SECRET_KEY` or stores the raw address as visitor identity. Without a
|
||||||
|
valid secret, a visitor who reaches the challenge threshold cannot submit more
|
||||||
|
anonymous ratings or Guide reports.
|
||||||
|
|
||||||
|
Edit staging configuration in Infisical project
|
||||||
|
`2ee94b27-e599-4194-9797-3372d42e53f6`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Environment: staging
|
||||||
|
Application path: /app
|
||||||
|
Scheduler path: /shared/scheduler
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `https://stage.shd.build/auth/discord/callback` to the Discord application's
|
Add `https://stage.shd.build/auth/discord/callback` to the Discord application's
|
||||||
allowed OAuth redirect URIs. Point the staging DNS record at the deployment
|
allowed OAuth redirect URIs. Point the staging DNS record at the deployment host
|
||||||
host and configure the host's TLS reverse proxy to forward
|
and configure the host's TLS reverse proxy to forward `stage.shd.build` to port
|
||||||
`stage.shd.build` to port `5569` on the staging host. Docker publishes this
|
`5569` on the staging host. Docker publishes this port on the host interfaces so
|
||||||
port on the host interfaces so a containerized reverse proxy or tunnel can
|
a containerized reverse proxy or tunnel can reach it; firewall the port to
|
||||||
reach it; firewall the port to trusted LAN/tunnel sources when the host is
|
trusted LAN/tunnel sources when the host is reachable from other networks.
|
||||||
reachable from other networks.
|
|
||||||
|
|
||||||
The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime
|
The trusted staging runner writes separate mode-`0600` Universal Auth client-ID
|
||||||
file. Gitea Actions holds the decryption key; Git and the container image do
|
and client-secret files for the app and scheduler. Compose mounts each pair only
|
||||||
not.
|
into its assigned service. Each container exchanges those credentials for a
|
||||||
|
short-lived access token and writes the assigned paths to mode-`0600` temporary
|
||||||
|
JSON files. The entrypoint rejects unknown secret names, deletes the token,
|
||||||
|
transfers the exports to the dedicated `shd-runtime` UID 10001, and drops root
|
||||||
|
before parsing them. The launcher deletes the files before validation,
|
||||||
|
migration, or startup. Startup fails if the mounted credential files are
|
||||||
|
readable by UID 10001 or by their group or other users. Runtime-created data
|
||||||
|
retains group access for rollback to the previous container UID.
|
||||||
|
|
||||||
|
| Machine identity | Environment | Injected paths | Gitea variable | Gitea secret |
|
||||||
|
| -------------------------------- | ----------- | --------------------------- | ------------------------------------------ | ---------------------------------------------- |
|
||||||
|
| `shd-build-staging-app` | `staging` | `/app`, `/shared/scheduler` | `INFISICAL_STAGING_APP_CLIENT_ID` | `INFISICAL_STAGING_APP_CLIENT_SECRET` |
|
||||||
|
| `shd-build-staging-scheduler` | `staging` | `/shared/scheduler` | `INFISICAL_STAGING_SCHEDULER_CLIENT_ID` | `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET` |
|
||||||
|
| `shd-build-production-app` | `prod` | `/app`, `/shared/scheduler` | `INFISICAL_PRODUCTION_APP_CLIENT_ID` | `INFISICAL_PRODUCTION_APP_CLIENT_SECRET` |
|
||||||
|
| `shd-build-production-scheduler` | `prod` | `/shared/scheduler` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET` |
|
||||||
|
|
||||||
|
The self-hosted Free plan allows five machine identities and does not include
|
||||||
|
Custom Project Roles. The four runtime identities use the built-in Viewer role,
|
||||||
|
which can read the complete project. Service-specific paths limit injected
|
||||||
|
values, but they do not restrict what a stolen root credential could request.
|
||||||
|
Use an Enterprise license with Custom Project Roles, or split runtime scopes
|
||||||
|
into separate projects, before treating identity authorization as least
|
||||||
|
privilege.
|
||||||
|
|
||||||
|
An Infisical outage does not stop a running container. A new container or direct
|
||||||
|
restart fails closed until `https://secrets.jb.codes` becomes available and the
|
||||||
|
identity can authenticate.
|
||||||
|
|
||||||
|
Keep the old `.env.*.runtime` files and `DOTENV_PRIVATE_KEY_*` Gitea secrets
|
||||||
|
only through the first successful staging and production cutovers. The rollback
|
||||||
|
path may need them to start the previous image. After both environments pass
|
||||||
|
direct restart checks, rotate every migrated provider credential in Infisical,
|
||||||
|
remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
|
||||||
|
and delete local and hosted dotenv key material. Removing encrypted files from
|
||||||
|
the current Git tree does not revoke values stored in Git history.
|
||||||
|
|
||||||
The staging container disables default-user seeding. On a brand-new staging
|
The staging container disables default-user seeding. On a brand-new staging
|
||||||
database, the existing bootstrap policy grants Admin to the first real account
|
database, the existing bootstrap policy grants Admin to the first real account
|
||||||
@@ -55,10 +104,10 @@ that signs in instead of writing generated credentials into deployment logs.
|
|||||||
|
|
||||||
## Gitea Actions runner
|
## Gitea Actions runner
|
||||||
|
|
||||||
Gitea Actions requires an `act_runner`. Register a trusted runner on the
|
Gitea Actions requires an `act_runner`. Register a trusted runner on the staging
|
||||||
staging host from the repository's **Settings → Actions → Runners** page. The
|
host from the repository's **Settings → Actions → Runners** page. The host needs
|
||||||
host needs Node/npm, Docker Engine, and Docker Compose v2. The runner account
|
Node/npm, Docker Engine, and Docker Compose v2. The runner account must be able
|
||||||
must be able to use Docker and write to the deployment directory.
|
to use Docker and write to the deployment directory.
|
||||||
|
|
||||||
Use runner labels that provide an isolated validation job and a host deployment
|
Use runner labels that provide an isolated validation job and a host deployment
|
||||||
job:
|
job:
|
||||||
@@ -85,41 +134,76 @@ Restart the runner session after adding it to the Docker group.
|
|||||||
|
|
||||||
In `https://git.jb.codes/jb/shd.build/settings/actions`:
|
In `https://git.jb.codes/jb/shd.build/settings/actions`:
|
||||||
|
|
||||||
1. Add repository secret `DOTENV_PRIVATE_KEY_STAGING`, using the matching value
|
1. Add repository secrets `INFISICAL_STAGING_APP_CLIENT_SECRET` and
|
||||||
from the ignored local `.env.keys`.
|
`INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET`.
|
||||||
2. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
|
2. Add repository variables `INFISICAL_STAGING_APP_CLIENT_ID` and
|
||||||
|
`INFISICAL_STAGING_SCHEDULER_CLIENT_ID`.
|
||||||
|
3. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
|
||||||
`/opt/shd-build-stage`.
|
`/opt/shd-build-stage`.
|
||||||
3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
|
4. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
|
||||||
4. Ensure Actions are enabled and the `stage-deploy` runner is online.
|
5. Ensure Actions are enabled and the `stage-deploy` runner is online.
|
||||||
|
|
||||||
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
|
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
|
||||||
dispatches. The trusted staging runner builds each commit-tagged image locally,
|
dispatches. The trusted staging runner builds each image locally with a tag that
|
||||||
so the workflow needs no registry credentials. Gitea assigns
|
contains the commit, workflow run, and attempt, so the workflow needs no
|
||||||
`gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives the
|
registry credentials and a retry cannot overwrite the active image. Gitea
|
||||||
next integer, while retries of the same run retain their number.
|
assigns `gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives
|
||||||
|
the next integer, while retries of the same run retain their number.
|
||||||
|
|
||||||
|
Staging and production deployments, ISAC exports, and production-to-staging
|
||||||
|
synchronization share the `shd-build-host-maintenance` concurrency group. Gitea
|
||||||
|
runs only one of these database maintenance workflows on the deployment host at
|
||||||
|
a time and never cancels one in progress.
|
||||||
|
|
||||||
## Deployment lifecycle
|
## Deployment lifecycle
|
||||||
|
|
||||||
The workflow:
|
The workflow stages Compose, credentials, and release metadata in an isolated
|
||||||
|
candidate directory. It leaves the canonical deployment files unchanged until
|
||||||
|
the candidate passes preflight, backup, and migration. The workflow then:
|
||||||
|
|
||||||
1. installs dependencies, runs `npm run check`, and produces the Node build;
|
1. installs dependencies, runs `npm run check` and `npm test`, and produces the
|
||||||
|
Node build;
|
||||||
2. builds the candidate container while the current site remains online;
|
2. builds the candidate container while the current site remains online;
|
||||||
3. stops only the staging application and notification scheduler;
|
3. starts one-off app and scheduler containers to verify Infisical access and
|
||||||
4. creates a timestamped JSON backup if the staging database exists;
|
validate the candidate environment;
|
||||||
5. applies additive, repeatable migrations;
|
4. stops only the staging application and notification scheduler;
|
||||||
6. starts the application and scheduler with the new image;
|
5. creates a timestamped JSON backup if the staging database exists;
|
||||||
7. waits for `/api/health` to report healthy;
|
6. applies additive, repeatable migrations in a one-off container;
|
||||||
8. retains the logs and exits unsuccessfully if health verification fails.
|
7. promotes the candidate files to the canonical deployment paths;
|
||||||
|
8. starts the application and scheduler with the new image;
|
||||||
|
9. validates the app environment and reruns the repeatable migration in the app
|
||||||
|
entrypoint before serving;
|
||||||
|
10. verifies `/api/health` and scheduler liveness;
|
||||||
|
11. restores the previous canonical files and verifies the previous app and
|
||||||
|
scheduler after a failed stop, backup, migration, startup, or health check.
|
||||||
|
|
||||||
|
On a failed health check, the deploy script prints the final 150 application log
|
||||||
|
lines and exits unsuccessfully. It does not create a separate log artifact.
|
||||||
|
|
||||||
The outage covers backup, migration, and container startup. Migrations may also
|
The outage covers backup, migration, and container startup. Migrations may also
|
||||||
remove records that use retired data formats when a release explicitly ends
|
remove records that use retired data formats when a release explicitly ends
|
||||||
compatibility. The deployment backup remains the recovery source for those
|
compatibility. The deployment backup remains the recovery source for those
|
||||||
records; the application does not repair or reinterpret them at runtime.
|
records; the application does not repair or reinterpret them at runtime. The
|
||||||
The scheduler calls a bearer-authenticated endpoint on the application service
|
scheduler calls a bearer-authenticated endpoint on the application service once
|
||||||
once per minute. Scheduled message delivery therefore uses the web process's
|
per minute. Scheduled message delivery therefore uses the web process's existing
|
||||||
existing embedded-database connection instead of opening the locked database
|
embedded-database connection instead of opening the locked database from a
|
||||||
from a second process. The endpoint runs automated system messages, Oversight
|
second process. The endpoint runs automated system messages, Oversight
|
||||||
consolidation, and email digest checks and is not published through a host port.
|
consolidation, email digest checks, and the Ubisoft News importer. Docker does
|
||||||
|
not publish the endpoint through a host port.
|
||||||
|
|
||||||
|
When enabled in production, the Ubisoft importer keeps its own persisted due
|
||||||
|
time, so the one-minute tick starts a feed synchronization no more than once per
|
||||||
|
hour after a successful run. When a synchronization fails, the importer stores
|
||||||
|
the last error and consecutive failure count, then retries after 15 minutes with
|
||||||
|
exponential backoff capped at six hours. Source items that disappear from the
|
||||||
|
feed remain published; the importer performs no automatic removal. The scheduler
|
||||||
|
runs notification delivery before it starts the bounded Ubisoft request, so a
|
||||||
|
slow provider does not postpone notifications for the current tick.
|
||||||
|
|
||||||
|
The runtime launcher sets `UBISOFT_NEWS_IMPORT_ENABLED=true` for production and
|
||||||
|
`false` for staging. Development does not set the flag, so the importer remains
|
||||||
|
disabled because it requires the exact value `true`. The application process
|
||||||
|
evaluates the flag when it handles `/api/internal/scheduler`.
|
||||||
|
|
||||||
## Prelaunch operation
|
## Prelaunch operation
|
||||||
|
|
||||||
@@ -131,8 +215,8 @@ for a database that does not contain the row:
|
|||||||
- News, Database, Builds, Guides, and Builder: enabled
|
- News, Database, Builds, Guides, and Builder: enabled
|
||||||
|
|
||||||
The migration uses an insert-if-missing seed. Later deployments preserve the
|
The migration uses an insert-if-missing seed. Later deployments preserve the
|
||||||
operator's settings, and configuration changes take effect without a restart.
|
operator's settings, and configuration changes take effect without a restart. A
|
||||||
A full environment bootstrap copies the source row. Canonical ISAC game-data
|
full environment bootstrap copies the source row. Canonical ISAC game-data
|
||||||
imports leave the target environment's launch settings unchanged.
|
imports leave the target environment's launch settings unchanged.
|
||||||
|
|
||||||
Use this operator flow after the first deployment:
|
Use this operator flow after the first deployment:
|
||||||
@@ -149,13 +233,13 @@ Use this operator flow after the first deployment:
|
|||||||
session before public release. Sign in and verify the same disabled routes
|
session before public release. Sign in and verify the same disabled routes
|
||||||
remain unavailable.
|
remain unavailable.
|
||||||
|
|
||||||
Keep Coming Soon enabled while preparing production. Section controls can be
|
Keep Coming Soon enabled while preparing production. Section controls can be set
|
||||||
set in advance because the launch gate takes precedence for signed-out traffic.
|
in advance because the launch gate takes precedence for signed-out traffic. Set
|
||||||
Set Account Creation to Disabled if prelaunch access must remain limited to
|
Account Creation to Disabled if prelaunch access must remain limited to existing
|
||||||
existing identities. Switch the Launch Gate to Public Launch after verification.
|
identities. Switch the Launch Gate to Public Launch after verification. Disabled
|
||||||
Disabled sections remain absent from navigation and unavailable by direct URL
|
sections remain absent from navigation and unavailable by direct URL for every
|
||||||
for every user, including authenticated agents. Authentication bypasses only
|
user, including authenticated agents. Authentication bypasses only the Coming
|
||||||
the Coming Soon gate.
|
Soon gate.
|
||||||
|
|
||||||
Turn Coming Soon on to restore the promotional gate. Site-control changes append
|
Turn Coming Soon on to restore the promotional gate. Site-control changes append
|
||||||
`system_configuration / site_access.updated` audit events, including the
|
`system_configuration / site_access.updated` audit events, including the
|
||||||
@@ -177,21 +261,21 @@ open while its complete transfer bundle is created:
|
|||||||
npm run stage:bootstrap:export
|
npm run stage:bootstrap:export
|
||||||
```
|
```
|
||||||
|
|
||||||
The command writes a timestamped, ignored JSON bundle under `data/backups/`.
|
The command writes a timestamped, ignored JSON bundle under `data/backups/`. The
|
||||||
The bundle includes user records and password hashes. Transfer it over an
|
bundle includes user records and password hashes. Transfer it over an encrypted
|
||||||
encrypted channel, keep it out of Git, then copy it to the staging host and run:
|
channel, keep it out of Git, then copy it to the staging host and run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
/opt/shd-build-stage/bootstrap-staging.sh \
|
/opt/shd-build-stage/bootstrap-staging.sh \
|
||||||
/secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
/secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap flow stops the app and scheduler, creates a safety backup of the
|
The bootstrap flow requires both services to stop, copies the bundle into the
|
||||||
target, verifies that staging is unused, imports the entire development
|
private data volume, assigns it to the runtime UID, creates a safety backup,
|
||||||
database and uploads, and restarts both services. Delete the transfer copy from
|
verifies that staging is unused, imports the entire development database and
|
||||||
the staging host after verifying the imported record counts and critical
|
uploads, deletes the volume copy, and restarts both services. Delete the host
|
||||||
entities. The copy under the local ignored backup directory remains the
|
transfer copy after verifying the imported record counts and critical entities.
|
||||||
recovery source.
|
The copy under the local ignored backup directory remains the recovery source.
|
||||||
|
|
||||||
Because this is a full initial clone, development session tokens and account
|
Because this is a full initial clone, development session tokens and account
|
||||||
data also enter staging. Staging and development remain independent from that
|
data also enter staging. Staging and development remain independent from that
|
||||||
@@ -250,16 +334,21 @@ environment, port, and deployment workflow:
|
|||||||
- Database: `/app/data/shd-build-production.db`
|
- Database: `/app/data/shd-build-production.db`
|
||||||
- Persistent volume: `shd-build-production-data`
|
- Persistent volume: `shd-build-production-data`
|
||||||
|
|
||||||
Routine pushes leave production data unchanged. The production deploy stops its
|
Routine pushes preserve production data unless an included migration removes a
|
||||||
application and scheduler, creates a timestamped JSON backup, applies repeatable
|
retired prototype format. The production deploy stops its application and
|
||||||
migrations, restarts, and verifies database health. Keep
|
scheduler, creates a timestamped JSON backup, applies repeatable migrations,
|
||||||
`docker compose down --volumes` away from production.
|
restarts, and verifies database health. Keep `docker compose down --volumes`
|
||||||
|
away from production.
|
||||||
|
|
||||||
Add these repository Action secrets:
|
Add these repository Action secrets:
|
||||||
|
|
||||||
- `DOTENV_PRIVATE_KEY_PRODUCTION`, matching the ignored local `.env.keys`
|
- `INFISICAL_PRODUCTION_APP_CLIENT_SECRET`
|
||||||
- `SCHEDULER_TOKEN_PRODUCTION`, a long random value used only by the internal
|
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET`
|
||||||
scheduler endpoint
|
|
||||||
|
Add the matching repository variables:
|
||||||
|
|
||||||
|
- `INFISICAL_PRODUCTION_APP_CLIENT_ID`
|
||||||
|
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID`
|
||||||
|
|
||||||
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
|
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
|
||||||
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
|
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
|
||||||
@@ -270,12 +359,12 @@ workflow run number supplies the production `SHD_BUILD_NUMBER`.
|
|||||||
Prepare the host directory once from an interactive SSH session:
|
Prepare the host directory once from an interactive SSH session:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo install -d -o jb -g jb -m 0750 /opt/shd-build-production
|
sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-production
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate the scheduler secret locally without printing it into a shared log,
|
Store a separate production `SCHEDULER_TOKEN` under Infisical path
|
||||||
then enter it directly in Gitea's repository-secret form. Do not commit it or
|
`/shared/scheduler`. The application and scheduler fetch the same value from
|
||||||
reuse the staging scheduler token.
|
that path. Do not reuse the staging token.
|
||||||
|
|
||||||
### Initial production bootstrap
|
### Initial production bootstrap
|
||||||
|
|
||||||
@@ -293,9 +382,34 @@ Copy the resulting ignored JSON bundle to the deployment host over SSH, then:
|
|||||||
/secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
/secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The importer verifies the bundle target, creates a production safety backup,
|
The bootstrap script requires both services to stop, stages the bundle in the
|
||||||
and refuses to replace a production database containing users, builds, audit
|
private data volume with runtime-only access, and removes that copy after the
|
||||||
events, or proposals. Remove the remote transfer copy after verification.
|
import. The importer verifies the bundle target, creates a production safety
|
||||||
|
backup, and refuses to replace a production database containing users, builds,
|
||||||
|
audit events, or proposals. Remove the remote host copy after verification.
|
||||||
|
|
||||||
|
### Promote one News article
|
||||||
|
|
||||||
|
Export a locally authored News article by ID or slug while the development
|
||||||
|
server is stopped:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run article:export -- <article-id-or-slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
The ignored bundle contains the article draft, immutable revisions, search row,
|
||||||
|
managed-image metadata, and image bytes. It excludes comments, reports, user
|
||||||
|
records, Builds, and ISAC data.
|
||||||
|
|
||||||
|
After deploying the matching application schema, stop the production app and
|
||||||
|
scheduler. Copy the transferred bundle into `/app/data` with `docker cp`, assign
|
||||||
|
it to `shd-runtime:node` with mode `0600` through a root one-off container, then
|
||||||
|
run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and
|
||||||
|
`--confirm-article-import`. The importer creates a production database backup
|
||||||
|
before opening the database, resolves the article owner by stable ID or
|
||||||
|
username, rejects ID and slug conflicts, verifies image checksums, and appends
|
||||||
|
`editorial_content / news.promoted`. Remove both bundle copies, restart the app
|
||||||
|
and scheduler, and health-check both services.
|
||||||
|
|
||||||
### Pull production ISAC data into development
|
### Pull production ISAC data into development
|
||||||
|
|
||||||
@@ -312,29 +426,29 @@ notifications, settings, and audit history untouched.
|
|||||||
|
|
||||||
### Pull production ISAC data into staging
|
### Pull production ISAC data into staging
|
||||||
|
|
||||||
Manually run **Replace staging ISAC data from production** in Gitea Actions.
|
Manually run **Replace staging ISAC data from production** in Gitea Actions. The
|
||||||
The workflow exports a consistent production snapshot, restarts production,
|
workflow exports a consistent production snapshot, restarts production, then
|
||||||
then stops staging and imports it. The staging importer creates full and
|
stops staging and imports it. The staging importer creates full and ISAC-only
|
||||||
ISAC-only safety backups before replacement. Deployments do not run this flow.
|
safety backups before replacement. Deployments do not run this flow.
|
||||||
|
|
||||||
### Promote managed images without replacing other data
|
### Promote managed images without replacing other data
|
||||||
|
|
||||||
Image Library assets use stable IDs and can be promoted additively from local
|
Image Library assets use stable IDs and can be promoted additively from local
|
||||||
development. Export a bundle after finishing local uploads and metadata edits:
|
development. Export a bundle after finishing local uploads and metadata edits:
|
||||||
|
|
||||||
Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that
|
Set the server-only `REMOVE_BG_API_KEY` under `/app` in each Infisical
|
||||||
accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header.
|
environment that accepts ISAC uploads. The server sends it only in the
|
||||||
Transparent icon uploads skip remove.bg. Opaque sources use its preview output,
|
`X-Api-Key` header. Transparent icon uploads skip remove.bg. Opaque sources use
|
||||||
then pass through local trimming, white normalization, WebP conversion, and
|
its preview output, then pass through local trimming, white normalization, WebP
|
||||||
storage.
|
conversion, and storage.
|
||||||
|
|
||||||
The application stores a remove.bg capacity snapshot and refreshes it from the
|
The application stores a remove.bg capacity snapshot and refreshes it from the
|
||||||
internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup
|
internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup
|
||||||
interval and defaults to six hours. Low-capacity alerts require both remaining
|
interval and defaults to six hours. Low-capacity alerts require both remaining
|
||||||
pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to
|
pools to cross their thresholds: `REMOVE_BG_LOW_CREDIT_THRESHOLD` defaults to
|
||||||
five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free
|
five paid credits, and `REMOVE_BG_LOW_FREE_CALL_THRESHOLD` defaults to ten free
|
||||||
calls. Alerts are mandatory ISAC notifications for active Administrators and
|
calls. Alerts are mandatory ISAC notifications for active Administrators and are
|
||||||
are deduplicated for each warning or critical episode.
|
deduplicated for each warning or critical episode.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run images:export
|
npm run images:export
|
||||||
@@ -348,19 +462,33 @@ embedded database is unlocked, and run the merge using the target image:
|
|||||||
```sh
|
```sh
|
||||||
# Staging
|
# Staging
|
||||||
cd /opt/shd-build-stage
|
cd /opt/shd-build-stage
|
||||||
docker compose -f compose.staging.yml stop scheduler app
|
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||||
docker compose -f compose.staging.yml run --rm \
|
docker compose --env-file .env.deploy -f compose.staging.yml create app
|
||||||
-v /secure/path:/import:ro app \
|
app_container=$(docker compose --env-file .env.deploy -f compose.staging.yml ps --all --quiet app)
|
||||||
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
|
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
|
||||||
docker compose -f compose.staging.yml up -d app scheduler
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||||
|
--entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app \
|
||||||
|
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||||
|
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
|
||||||
|
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
cd /opt/shd-build-production
|
cd /opt/shd-build-production
|
||||||
docker compose -f compose.production.yml stop scheduler app
|
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
|
||||||
docker compose -f compose.production.yml run --rm \
|
docker compose --env-file .env.deploy -f compose.production.yml create app
|
||||||
-v /secure/path:/import:ro app \
|
app_container=$(docker compose --env-file .env.deploy -f compose.production.yml ps --all --quiet app)
|
||||||
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
|
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
|
||||||
docker compose -f compose.production.yml up -d app scheduler
|
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||||
|
--entrypoint sh --user root app -c \
|
||||||
|
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps app \
|
||||||
|
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||||
|
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
|
||||||
|
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
|
||||||
```
|
```
|
||||||
|
|
||||||
Each import creates a full target database backup before changes. The importer
|
Each import creates a full target database backup before changes. The importer
|
||||||
@@ -368,12 +496,11 @@ adds new stable IDs, skips identical assets, and blocks conflicting records.
|
|||||||
After reviewing an intentional metadata or file replacement, add
|
After reviewing an intentional metadata or file replacement, add
|
||||||
`--update-existing`. Imported changes append `image.imported` or
|
`--update-existing`. Imported changes append `image.imported` or
|
||||||
`image.import_updated` audit events. Remove the transferred bundle after
|
`image.import_updated` audit events. Remove the transferred bundle after
|
||||||
verifying both environments because it may contain unpublished visual assets
|
verifying both environments because it may contain unpublished visual assets and
|
||||||
and creator identifiers.
|
creator identifiers.
|
||||||
|
|
||||||
The `.env.staging` and `.env.production` npm import commands are intended for
|
The staging and production npm import commands fetch `/app` from Infisical. Run
|
||||||
maintenance performed directly in an environment where those database and
|
them only where the target database and storage paths are mounted:
|
||||||
storage paths are mounted:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
|
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
|
||||||
|
|||||||
+1157
-894
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
# Weapon baseline prototype
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
`calculateWeaponBaseline` is a pure, client-safe Builder helper. It records
|
||||||
|
weapon ID, name, and label, then reports three evidence-only metrics:
|
||||||
|
|
||||||
|
- `selectedDamage`: the supplied positive finite damage value.
|
||||||
|
- `ratedBodyHitThroughput`: `selectedDamage × rpm / 60`.
|
||||||
|
- `magazineCycleBodyHitThroughput`:
|
||||||
|
`(selectedDamage × magazine) / (magazine / (rpm / 60) + reloadSeconds)`.
|
||||||
|
|
||||||
|
Unknown, absent, non-positive, non-finite, or invalid source values make only
|
||||||
|
the metrics that depend on them unavailable. The helper preserves decimal inputs
|
||||||
|
and does not substitute values.
|
||||||
|
|
||||||
|
These are not actual, expected, or encounter DPS. They contain no hit
|
||||||
|
probability, critical or headshot outcomes, target state, or encounter timeline.
|
||||||
|
The rated metric is nominal continuous body-hit throughput; the magazine-cycle
|
||||||
|
metric includes the supplied reload interval.
|
||||||
|
|
||||||
|
## Excluded inputs
|
||||||
|
|
||||||
|
This helper does not read combat profiles, effects, talents, Attribute totals,
|
||||||
|
calculation profiles, encounter assumptions, or any other build inputs. It does
|
||||||
|
not call the simulation or combat engines.
|
||||||
|
|
||||||
|
`selectedDamage` is already the equipped weapon damage used at this boundary. It
|
||||||
|
is consumed exactly once; no additional level scaling or damage scaling is
|
||||||
|
applied.
|
||||||
|
|
||||||
|
## Missing data for a DPS model
|
||||||
|
|
||||||
|
A valid DPS model still needs the authoritative damage calculation and its
|
||||||
|
modifiers, hit and critical/headshot assumptions, firing and reload behavior,
|
||||||
|
target and encounter assumptions, and the timing and contribution of talents,
|
||||||
|
effects, Attributes, skills, and weapon swaps. Those data are outside this
|
||||||
|
baseline.
|
||||||
|
|
||||||
|
This is Builder-only, local prototype work. It does not define the player DPS
|
||||||
|
simulation contract or change the existing calculation architecture.
|
||||||
@@ -26,10 +26,17 @@ Use these event boundaries:
|
|||||||
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
- **Healing:** Count each healing event. A heal-over-time effect counts each
|
||||||
tick as a separate event.
|
tick as a separate event.
|
||||||
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
- **Extra Health gained:** Count every Extra Health grant, including a grant
|
||||||
received while the Agent already has Extra Health.
|
received while the Agent already has Extra Health. Use `extra_health_granted`
|
||||||
|
for the grant action.
|
||||||
|
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
|
||||||
|
it when the Agent receives a grant, when the remaining amount changes, and
|
||||||
|
when it is depleted to zero. Every event requires the boolean
|
||||||
|
`values.hasExtraHealth`. It may include the non-negative
|
||||||
|
`values.currentExtraHealth`, which must agree with that status. This state
|
||||||
|
event is distinct from the `extra_health_granted` grant action.
|
||||||
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
- **Reloading:** Trigger on reload completion when the weapon had at least one
|
||||||
round to load. A canceled reload does not count. A weapon that loads one
|
round to load. A canceled reload does not count. A weapon that loads one round
|
||||||
round at a time produces one completed-reload event for each round.
|
at a time produces one completed-reload event for each round.
|
||||||
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
|
||||||
not count as taking damage.
|
not count as taking damage.
|
||||||
|
|
||||||
@@ -52,8 +59,8 @@ Inspiration and Crossfire use a binary deployed-Skill state:
|
|||||||
|
|
||||||
- The benefit remains active while the Agent has at least one persistent
|
- The benefit remains active while the Agent has at least one persistent
|
||||||
deployed Skill object.
|
deployed Skill object.
|
||||||
- Destruction or timed expiration removes that object from the active count.
|
- Destruction or timed expiration removes that object from the active count. The
|
||||||
The benefit ends when no qualifying objects remain.
|
benefit ends when no qualifying objects remain.
|
||||||
- Instant-use Skills do not qualify.
|
- Instant-use Skills do not qualify.
|
||||||
- Multiple deployed Skill objects do not multiply the benefit.
|
- Multiple deployed Skill objects do not multiply the benefit.
|
||||||
|
|
||||||
@@ -68,8 +75,8 @@ meters:
|
|||||||
|
|
||||||
- Moving within 30 meters of an ally who is already downed qualifies.
|
- Moving within 30 meters of an ally who is already downed qualifies.
|
||||||
- Each qualifying presence refreshes the ten-second duration.
|
- Each qualifying presence refreshes the ten-second duration.
|
||||||
- Leaving the radius or reviving the ally does not cancel the current
|
- Leaving the radius or reviving the ally does not cancel the current ten-second
|
||||||
ten-second duration.
|
duration.
|
||||||
- The wearer does not count as an ally.
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
## Emergency Retreat
|
## Emergency Retreat
|
||||||
@@ -86,9 +93,9 @@ qualifies when the weapon had at least one round to load. This includes each
|
|||||||
round inserted by a weapon that reloads one round at a time. A canceled reload
|
round inserted by a weapon that reloads one round at a time. A canceled reload
|
||||||
does not qualify.
|
does not qualify.
|
||||||
|
|
||||||
Resolve the Extra Health amount from the Agent's displayed Firepower when
|
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
|
||||||
Second Wind activates. Include all equipped-item and active-buff contributions
|
Wind activates. Include all equipped-item and active-buff contributions in that
|
||||||
in that Firepower value:
|
Firepower value:
|
||||||
|
|
||||||
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
- Tier I grants Extra Health equal to 600% of displayed Firepower.
|
||||||
- Tier II grants Extra Health equal to 840% of displayed Firepower.
|
- Tier II grants Extra Health equal to 840% of displayed Firepower.
|
||||||
@@ -96,19 +103,39 @@ in that Firepower value:
|
|||||||
Second Wind adds its Extra Health to Extra Health from other sources. Its
|
Second Wind adds its Extra Health to Extra Health from other sources. Its
|
||||||
five-second duration and five-second cooldown start when it activates. Expiry
|
five-second duration and five-second cooldown start when it activates. Expiry
|
||||||
removes only the Second Wind contribution. At the five-second boundary, the
|
removes only the Second Wind contribution. At the five-second boundary, the
|
||||||
prior grant can expire as a qualifying reload completion grants a new amount.
|
prior grant can expire as a qualifying reload completion grants a new amount. If
|
||||||
If Second Wind supplies the Agent's only Extra Health, the old amount expires
|
Second Wind supplies the Agent's only Extra Health, the old amount expires and
|
||||||
and the new amount begins at that boundary.
|
the new amount begins at that boundary.
|
||||||
|
|
||||||
## Nothing to Lose
|
## Nothing to Lose
|
||||||
|
|
||||||
Nothing to Lose uses a forward-looking deployment window:
|
Nothing to Lose uses a forward-looking deployment window:
|
||||||
|
|
||||||
- Each Health-damage instance makes its own 50% activation roll. Damage
|
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
|
||||||
absorbed by Extra Health does not roll.
|
by Extra Health does not roll.
|
||||||
- A successful activation applies to every Skill deployed during the five
|
- A successful activation applies to every Skill deployed during the five
|
||||||
seconds after that damage instance. Several Skills can qualify during the
|
seconds after that damage instance. Several Skills can qualify during the same
|
||||||
same window.
|
window.
|
||||||
- Calculate each reduction from the Skill's total cooldown after equipment
|
- Calculate each reduction from the Skill's total cooldown after equipment
|
||||||
modifiers.
|
modifiers.
|
||||||
- A Skill can receive no more than three reductions during one cooldown cycle.
|
- A Skill can receive no more than three reductions during one cooldown cycle.
|
||||||
|
|
||||||
|
## OS Protocol Talents
|
||||||
|
|
||||||
|
Happy Survival restores Health once per second while it has a qualifying nearby
|
||||||
|
enemy count. Each restoration resolves 0.5% of maximum Health per enemy within
|
||||||
|
10 meters, capped at 2.5% before OS Protocol upgrade scaling.
|
||||||
|
|
||||||
|
Explosive Charge requires three seconds of uninterrupted firing. Completion
|
||||||
|
opens an eight-second window to deploy the next Skill. The window ends when the
|
||||||
|
Agent deploys that Skill. Only the enhanced Skill's first damaging hit creates
|
||||||
|
the five-meter explosion. The shared Talent cooldown rule starts its five-second
|
||||||
|
cooldown when that explosion activates.
|
||||||
|
|
||||||
|
Heavy Armor and Pummeling Shield replace and refresh their own active Extra
|
||||||
|
Health grant when the same Talent activates again. Extra Health grants from
|
||||||
|
different sources remain separate.
|
||||||
|
|
||||||
|
Pummeling Shield counts confirmed weapon hits while the Agent possesses Extra
|
||||||
|
Health. Losing all Extra Health resets the hit count. The full-Health condition
|
||||||
|
checks Health immediately before the incoming hit resolves.
|
||||||
|
|||||||
+542
-287
File diff suppressed because it is too large
Load Diff
Generated
+4602
-3702
File diff suppressed because it is too large
Load Diff
+82
-80
@@ -1,82 +1,84 @@
|
|||||||
{
|
{
|
||||||
"name": "shd-build",
|
"name": "shd-build",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0",
|
"dev": "infisical run --env=dev --path=/app --path=/shared/scheduler -- vite dev --host 0.0.0.0",
|
||||||
"build:app": "vite build",
|
"build:app": "vite build",
|
||||||
"build:staging": "dotenvx run -f .env.staging -- vite build",
|
"build:staging": "vite build",
|
||||||
"build": "dotenvx run -f .env.production -- vite build",
|
"build": "vite build",
|
||||||
"brand:render": "node scripts/render-brand-assets.mjs",
|
"brand:render": "node scripts/render-brand-assets.mjs",
|
||||||
"start": "node build",
|
"start": "node build",
|
||||||
"preview": "node build",
|
"preview": "node build",
|
||||||
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs",
|
"db:migrate": "infisical run --env=dev --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||||
"db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs",
|
"db:migrate:staging": "infisical run --env=staging --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||||
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs",
|
"db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||||
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs",
|
"db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs",
|
||||||
"db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords",
|
"db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
|
||||||
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs",
|
"db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
|
||||||
"db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs",
|
"db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
|
||||||
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs",
|
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
|
||||||
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
|
"db:restore": "infisical run --env=dev --path=/app -- node scripts/db-restore.mjs",
|
||||||
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs",
|
"db:restore:production": "infisical run --env=prod --path=/app -- node scripts/db-restore.mjs",
|
||||||
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs",
|
"article:export": "infisical run --env=dev --path=/app -- node scripts/article-transfer.mjs export",
|
||||||
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs",
|
"isac:export": "infisical run --env=dev --path=/app -- node scripts/isac-data-export.mjs",
|
||||||
"isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs",
|
"isac:import": "infisical run --env=dev --path=/app -- node scripts/isac-data-import.mjs",
|
||||||
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs",
|
"isac:attributes:import": "infisical run --env=dev --path=/app -- node scripts/import-resurgence-attributes.mjs",
|
||||||
"images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs",
|
"isac:export:staging": "infisical run --env=staging --path=/app -- node scripts/isac-data-export.mjs",
|
||||||
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs",
|
"images:export": "infisical run --env=dev --path=/app -- node scripts/managed-images-export.mjs",
|
||||||
"images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs",
|
"images:import": "infisical run --env=dev --path=/app -- node scripts/managed-images-import.mjs",
|
||||||
"images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs",
|
"images:import:staging": "infisical run --env=staging --path=/app -- node scripts/managed-images-import.mjs",
|
||||||
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs",
|
"images:import:production": "infisical run --env=prod --path=/app -- node scripts/managed-images-import.mjs",
|
||||||
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production",
|
"stage:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/stage-bootstrap-export.mjs",
|
||||||
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
|
"production:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/environment-bootstrap-export.mjs production",
|
||||||
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs",
|
"notifications:digest": "infisical run --env=dev --path=/app -- node scripts/notification-digest.mjs",
|
||||||
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs",
|
"notifications:digest:production": "infisical run --env=prod --path=/app -- node scripts/notification-digest.mjs",
|
||||||
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs",
|
"notifications:oversight": "infisical run --env=dev --path=/app -- node scripts/oversight-notification-dispatch.mjs",
|
||||||
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
|
"notifications:oversight:production": "infisical run --env=prod --path=/app -- node scripts/oversight-notification-dispatch.mjs",
|
||||||
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs",
|
"notifications:system": "infisical run --env=dev --path=/app -- node scripts/system-notification-dispatch.mjs",
|
||||||
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
|
"notifications:system:production": "infisical run --env=prod --path=/app -- node scripts/system-notification-dispatch.mjs",
|
||||||
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
"notifications:seed-test": "infisical run --env=dev --path=/app -- node scripts/seed-test-notifications.mjs",
|
||||||
"env:encrypt": "node scripts/env-files.mjs encrypt",
|
"assets:trim-icons": "node scripts/trim-static-icons.mjs",
|
||||||
"env:decrypt": "node scripts/env-files.mjs decrypt",
|
"prepare": "node scripts/prepare.mjs",
|
||||||
"env:encrypt:development": "dotenvx encrypt -f .env.development",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"env:decrypt:development": "dotenvx decrypt -f .env.development",
|
"test": "vitest run",
|
||||||
"env:encrypt:staging": "dotenvx encrypt -f .env.staging",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
"env:decrypt:staging": "dotenvx decrypt -f .env.staging",
|
},
|
||||||
"env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs",
|
"lint-staged": {
|
||||||
"env:encrypt:production": "dotenvx encrypt -f .env.production",
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
"env:decrypt:production": "dotenvx decrypt -f .env.production",
|
},
|
||||||
"env:stage": "node scripts/env-files.mjs stage",
|
"devDependencies": {
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"@infisical/cli": "0.43.116",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"test": "vitest run",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
},
|
"@types/node": "^26.1.1",
|
||||||
"devDependencies": {
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@dotenvx/dotenvx": "^2.17.4",
|
"husky": "^9.1.7",
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"lint-staged": "^16.4.0",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"opentype.js": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"prettier": "3.9.6",
|
||||||
"@types/node": "^26.1.1",
|
"svelte": "^5.56.1",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"svelte-check": "^4.6.0",
|
||||||
"opentype.js": "^2.0.0",
|
"typescript": "^6.0.3",
|
||||||
"svelte": "^5.56.1",
|
"vite": "^8.0.16",
|
||||||
"svelte-check": "^4.6.0",
|
"vitest": "^4.1.10"
|
||||||
"typescript": "^6.0.3",
|
},
|
||||||
"vite": "^8.0.16",
|
"dependencies": {
|
||||||
"vitest": "^4.1.10"
|
"@fontsource/barlow": "^5.3.0",
|
||||||
},
|
"@fontsource/source-serif-4": "^5.3.0",
|
||||||
"dependencies": {
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@fontsource/barlow": "^5.3.0",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"@fontsource/source-serif-4": "^5.3.0",
|
"@tiptap/core": "3.29.2",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@tiptap/extension-link": "3.29.2",
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"@tiptap/extensions": "3.29.2",
|
||||||
"@tursodatabase/database": "^0.7.1",
|
"@tiptap/pm": "3.29.2",
|
||||||
"argon2": "^0.45.1",
|
"@tiptap/starter-kit": "3.29.2",
|
||||||
"nodemailer": "^9.0.3",
|
"@tursodatabase/database": "^0.7.1",
|
||||||
"openai": "^6.49.0",
|
"argon2": "^0.45.1",
|
||||||
"sharp": "^0.35.3"
|
"nodemailer": "^9.0.3",
|
||||||
}
|
"openai": "^6.49.0",
|
||||||
|
"sharp": "^0.35.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { connect } from '@tursodatabase/database';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, link, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const subject = process.argv[3];
|
||||||
|
const confirmation = '--confirm-article-import';
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const imageDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const STORAGE_KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i;
|
||||||
|
|
||||||
|
const failUsage = () => {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/article-transfer.mjs export <article-id-or-slug> [output.json]',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
` or: ARTICLE_IMPORT_ENABLED=true node scripts/article-transfer.mjs import <bundle.json> ${confirmation}`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!['export', 'import'].includes(mode) || !subject) failUsage();
|
||||||
|
|
||||||
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
||||||
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
const isRecord = (value) =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
async function insertRow(database, table, row) {
|
||||||
|
const columns = Object.keys(row);
|
||||||
|
if (!columns.length) throw new Error(`Cannot insert an empty ${table} row.`);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(',')}) VALUES (${columns.map(() => '?').join(',')})`,
|
||||||
|
)
|
||||||
|
).run(...columns.map((column) => row[column]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportArticle() {
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const article = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM articles WHERE id=? OR slug=? ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1`,
|
||||||
|
)
|
||||||
|
).get(subject, subject, subject);
|
||||||
|
if (!article) throw new Error('Article not found.');
|
||||||
|
if (article.kind !== 'news')
|
||||||
|
throw new Error('Only News articles can use this transfer command.');
|
||||||
|
if (article.deleted_at)
|
||||||
|
throw new Error('Deleted News articles cannot be transferred.');
|
||||||
|
const external = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT article_id FROM external_news_sources WHERE article_id=?',
|
||||||
|
)
|
||||||
|
).get(article.id);
|
||||||
|
if (external)
|
||||||
|
throw new Error(
|
||||||
|
'Syndicated News articles must come from their source feed.',
|
||||||
|
);
|
||||||
|
const embeds = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM article_revision_build_embeds
|
||||||
|
WHERE revision_id IN (SELECT id FROM article_revisions WHERE article_id=?)`,
|
||||||
|
)
|
||||||
|
).get(article.id);
|
||||||
|
if (Number(embeds?.count ?? 0) > 0)
|
||||||
|
throw new Error('News article transfers cannot contain Build embeds.');
|
||||||
|
|
||||||
|
const owner = await (
|
||||||
|
await database.prepare('SELECT id,username FROM users WHERE id=?')
|
||||||
|
).get(article.owner_id);
|
||||||
|
if (!owner?.username)
|
||||||
|
throw new Error('The article owner does not have a transferable username.');
|
||||||
|
const draft = await (
|
||||||
|
await database.prepare('SELECT * FROM article_drafts WHERE article_id=?')
|
||||||
|
).get(article.id);
|
||||||
|
const revisions = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM article_revisions WHERE article_id=? ORDER BY revision_number',
|
||||||
|
)
|
||||||
|
).all(article.id);
|
||||||
|
if (!draft || !revisions.length)
|
||||||
|
throw new Error('The article is missing its draft or revision history.');
|
||||||
|
const revisionIds = revisions.map((revision) => String(revision.id));
|
||||||
|
const placeholders = revisionIds.map(() => '?').join(',');
|
||||||
|
const revisionTags = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM article_revision_tags WHERE revision_id IN (${placeholders}) ORDER BY revision_id,ordinal`,
|
||||||
|
)
|
||||||
|
).all(...revisionIds);
|
||||||
|
const media = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT * FROM article_media WHERE article_id=? ORDER BY image_id',
|
||||||
|
)
|
||||||
|
).all(article.id);
|
||||||
|
const search = await (
|
||||||
|
await database.prepare('SELECT * FROM article_search WHERE article_id=?')
|
||||||
|
).get(article.id);
|
||||||
|
const imageIds = [...new Set(media.map((row) => String(row.image_id)))];
|
||||||
|
const images = imageIds.length
|
||||||
|
? await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT * FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) ORDER BY id`,
|
||||||
|
)
|
||||||
|
).all(...imageIds)
|
||||||
|
: [];
|
||||||
|
if (images.length !== imageIds.length)
|
||||||
|
throw new Error('The article references missing managed-image metadata.');
|
||||||
|
const files = [];
|
||||||
|
for (const image of images) {
|
||||||
|
const storageKey = String(image.storage_key);
|
||||||
|
if (!STORAGE_KEY_PATTERN.test(storageKey))
|
||||||
|
throw new Error('The article uses an unsupported managed-image key.');
|
||||||
|
const bytes = await readFile(join(imageDirectory, storageKey));
|
||||||
|
if (bytes.byteLength !== Number(image.byte_size))
|
||||||
|
throw new Error(`Managed image ${image.id} has an invalid byte size.`);
|
||||||
|
files.push({
|
||||||
|
storageKey,
|
||||||
|
byteSize: bytes.byteLength,
|
||||||
|
sha256: hash(bytes),
|
||||||
|
contentBase64: bytes.toString('base64'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = {
|
||||||
|
format: 'shd-build-article-transfer',
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
owner: { sourceId: String(owner.id), username: String(owner.username) },
|
||||||
|
article,
|
||||||
|
draft,
|
||||||
|
revisions,
|
||||||
|
revisionTags,
|
||||||
|
media,
|
||||||
|
search: search ?? null,
|
||||||
|
images,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[4] ||
|
||||||
|
`./data/backups/article-${String(article.slug)}-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Exported News article "${article.slug}" with ${revisions.length} revisions and ${files.length} images.`,
|
||||||
|
);
|
||||||
|
console.log(`Article bundle: ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBundle(bundle) {
|
||||||
|
if (
|
||||||
|
!isRecord(bundle) ||
|
||||||
|
bundle.format !== 'shd-build-article-transfer' ||
|
||||||
|
bundle.version !== 1 ||
|
||||||
|
!isRecord(bundle.owner) ||
|
||||||
|
!isRecord(bundle.article) ||
|
||||||
|
!isRecord(bundle.draft) ||
|
||||||
|
!Array.isArray(bundle.revisions) ||
|
||||||
|
!Array.isArray(bundle.revisionTags) ||
|
||||||
|
!Array.isArray(bundle.media) ||
|
||||||
|
!Array.isArray(bundle.images) ||
|
||||||
|
!Array.isArray(bundle.files) ||
|
||||||
|
bundle.article.kind !== 'news' ||
|
||||||
|
!UUID_PATTERN.test(String(bundle.article.id)) ||
|
||||||
|
!bundle.owner.username ||
|
||||||
|
!bundle.revisions.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported News article bundle.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const articleId = String(bundle.article.id);
|
||||||
|
if (
|
||||||
|
bundle.draft.article_id !== articleId ||
|
||||||
|
bundle.revisions.some(
|
||||||
|
(revision) =>
|
||||||
|
!isRecord(revision) ||
|
||||||
|
revision.article_id !== articleId ||
|
||||||
|
!UUID_PATTERN.test(String(revision.id)),
|
||||||
|
) ||
|
||||||
|
bundle.media.some(
|
||||||
|
(row) => !isRecord(row) || row.article_id !== articleId,
|
||||||
|
) ||
|
||||||
|
bundle.images.some(
|
||||||
|
(image) =>
|
||||||
|
!isRecord(image) ||
|
||||||
|
!UUID_PATTERN.test(String(image.id)) ||
|
||||||
|
!STORAGE_KEY_PATTERN.test(String(image.storage_key)) ||
|
||||||
|
image.source_entity_type !== null ||
|
||||||
|
image.source_entity_id !== null,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('The News article bundle contains inconsistent records.');
|
||||||
|
}
|
||||||
|
const imageIds = new Set(bundle.images.map((image) => String(image.id)));
|
||||||
|
const files = new Map();
|
||||||
|
for (const file of bundle.files) {
|
||||||
|
if (
|
||||||
|
!isRecord(file) ||
|
||||||
|
!STORAGE_KEY_PATTERN.test(String(file.storageKey)) ||
|
||||||
|
!Number.isInteger(file.byteSize) ||
|
||||||
|
file.byteSize <= 0 ||
|
||||||
|
typeof file.sha256 !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(file.sha256) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The News article bundle contains an invalid image file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bytes = Buffer.from(file.contentBase64, 'base64');
|
||||||
|
if (bytes.byteLength !== file.byteSize || hash(bytes) !== file.sha256)
|
||||||
|
throw new Error('The News article bundle contains a corrupt image file.');
|
||||||
|
files.set(String(file.storageKey), bytes);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bundle.images.some((image) => !files.has(String(image.storage_key))) ||
|
||||||
|
bundle.media.some((row) => !imageIds.has(String(row.image_id)))
|
||||||
|
) {
|
||||||
|
throw new Error('The News article bundle is missing image dependencies.');
|
||||||
|
}
|
||||||
|
const revisionIds = new Set(
|
||||||
|
bundle.revisions.map((revision) => String(revision.id)),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!revisionIds.has(String(bundle.article.published_revision_id)) ||
|
||||||
|
(bundle.article.pending_revision_id &&
|
||||||
|
!revisionIds.has(String(bundle.article.pending_revision_id))) ||
|
||||||
|
bundle.revisionTags.some((row) => !revisionIds.has(String(row.revision_id)))
|
||||||
|
) {
|
||||||
|
throw new Error('The News article bundle has invalid revision pointers.');
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importArticle() {
|
||||||
|
if (
|
||||||
|
process.env.ARTICLE_IMPORT_ENABLED !== 'true' ||
|
||||||
|
!process.argv.includes(confirmation)
|
||||||
|
) {
|
||||||
|
failUsage();
|
||||||
|
}
|
||||||
|
const bundlePath = resolve(subject);
|
||||||
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
|
const files = validateBundle(bundle);
|
||||||
|
|
||||||
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (safetyBackup.error) throw safetyBackup.error;
|
||||||
|
if (safetyBackup.status !== 0)
|
||||||
|
throw new Error('Article import stopped because its safety backup failed.');
|
||||||
|
|
||||||
|
const database = await connect(databasePath);
|
||||||
|
const existing = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,slug FROM articles WHERE id=? OR (kind=? AND slug=?)',
|
||||||
|
)
|
||||||
|
).get(bundle.article.id, bundle.article.kind, bundle.article.slug);
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.id === bundle.article.id &&
|
||||||
|
existing.slug === bundle.article.slug
|
||||||
|
) {
|
||||||
|
console.log(`News article "${existing.slug}" is already present.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('The target already contains a conflicting News article.');
|
||||||
|
}
|
||||||
|
const owner = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id,username FROM users WHERE id=? OR lower(username)=lower(?) ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1',
|
||||||
|
)
|
||||||
|
).get(bundle.owner.sourceId, bundle.owner.username, bundle.owner.sourceId);
|
||||||
|
if (!owner)
|
||||||
|
throw new Error(
|
||||||
|
`The target does not contain Agent "${bundle.owner.username}".`,
|
||||||
|
);
|
||||||
|
const tagIds = [
|
||||||
|
...new Set(bundle.revisionTags.map((row) => String(row.tag_id))),
|
||||||
|
];
|
||||||
|
if (tagIds.length) {
|
||||||
|
const available = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM article_tags WHERE id IN (${tagIds.map(() => '?').join(',')})`,
|
||||||
|
)
|
||||||
|
).get(...tagIds);
|
||||||
|
if (Number(available?.count ?? 0) !== tagIds.length)
|
||||||
|
throw new Error('The target is missing one or more article tags.');
|
||||||
|
}
|
||||||
|
const imageIds = bundle.images.map((image) => String(image.id));
|
||||||
|
if (imageIds.length) {
|
||||||
|
const collision = await (
|
||||||
|
await database.prepare(
|
||||||
|
`SELECT id FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) OR storage_key IN (${imageIds.map(() => '?').join(',')}) LIMIT 1`,
|
||||||
|
)
|
||||||
|
).get(...imageIds, ...bundle.images.map((image) => image.storage_key));
|
||||||
|
if (collision)
|
||||||
|
throw new Error('The target contains a conflicting managed image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(imageDirectory, { recursive: true });
|
||||||
|
const createdFiles = [];
|
||||||
|
let transactionStarted = false;
|
||||||
|
try {
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
const storageKey = String(image.storage_key);
|
||||||
|
const temporaryPath = join(
|
||||||
|
imageDirectory,
|
||||||
|
`.${storageKey}.${randomUUID()}.tmp`,
|
||||||
|
);
|
||||||
|
await writeFile(temporaryPath, files.get(storageKey), {
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const finalPath = join(imageDirectory, storageKey);
|
||||||
|
try {
|
||||||
|
await link(temporaryPath, finalPath);
|
||||||
|
createdFiles.push(finalPath);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
|
transactionStarted = true;
|
||||||
|
for (const image of bundle.images) {
|
||||||
|
await insertRow(database, 'managed_images', {
|
||||||
|
...image,
|
||||||
|
created_by: owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await insertRow(database, 'articles', {
|
||||||
|
...bundle.article,
|
||||||
|
owner_id: owner.id,
|
||||||
|
visibility: 'private',
|
||||||
|
published_revision_id: null,
|
||||||
|
pending_revision_id: null,
|
||||||
|
});
|
||||||
|
await insertRow(database, 'article_drafts', {
|
||||||
|
...bundle.draft,
|
||||||
|
updated_by: owner.id,
|
||||||
|
});
|
||||||
|
for (const revision of bundle.revisions) {
|
||||||
|
await insertRow(database, 'article_revisions', {
|
||||||
|
...revision,
|
||||||
|
created_by: owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of bundle.revisionTags)
|
||||||
|
await insertRow(database, 'article_revision_tags', row);
|
||||||
|
for (const row of bundle.media) {
|
||||||
|
await insertRow(database, 'article_media', {
|
||||||
|
...row,
|
||||||
|
uploaded_by: owner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bundle.search)
|
||||||
|
await insertRow(database, 'article_search', bundle.search);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
`UPDATE articles SET visibility=?,published_revision_id=?,pending_revision_id=? WHERE id=?`,
|
||||||
|
)
|
||||||
|
).run(
|
||||||
|
bundle.article.visibility,
|
||||||
|
bundle.article.published_revision_id,
|
||||||
|
bundle.article.pending_revision_id,
|
||||||
|
bundle.article.id,
|
||||||
|
);
|
||||||
|
await insertRow(database, 'audit_log', {
|
||||||
|
id: randomUUID(),
|
||||||
|
actor_id: owner.id,
|
||||||
|
entity_type: 'editorial_content',
|
||||||
|
entity_id: bundle.article.id,
|
||||||
|
action: 'news.promoted',
|
||||||
|
before_json: null,
|
||||||
|
after_json: JSON.stringify({
|
||||||
|
sourceBundle: basename(bundlePath),
|
||||||
|
slug: bundle.article.slug,
|
||||||
|
publishedRevisionId: bundle.article.published_revision_id,
|
||||||
|
revisions: bundle.revisions.length,
|
||||||
|
images: bundle.images.length,
|
||||||
|
}),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
transactionStarted = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (transactionStarted) {
|
||||||
|
try {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
} catch {
|
||||||
|
// Preserve the import error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of createdFiles) await rm(file, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Imported News article "${bundle.article.slug}" with ${bundle.revisions.length} revisions and ${bundle.images.length} images for ${owner.username}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'export') await exportArticle();
|
||||||
|
else await importArticle();
|
||||||
@@ -1,40 +1,304 @@
|
|||||||
[
|
[
|
||||||
{"name":"Weapon Critical Hit Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2.4,6]},
|
{
|
||||||
{"name":"Weapon Critical Hit Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]},
|
"name": "Weapon Critical Hit Chance",
|
||||||
{"name":"Weapon Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
"unit": "%",
|
||||||
{"name":"Weapon Multi-Shot Chance","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
"type": "Offensive",
|
||||||
{"name":"Skill Critical Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2.4,6]},
|
"category": "Weapon",
|
||||||
{"name":"Skill Critical Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]},
|
"calculationType": "Additive",
|
||||||
{"name":"Skill Intensity","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
"gearRange": [2.4, 6]
|
||||||
{"name":"Skill Multi-Shot Chance","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[2,5],"weaponRange":[4,10]},
|
},
|
||||||
{"name":"Headshot Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]},
|
{
|
||||||
{"name":"Max Health","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4,10],"weaponRange":[8,20]},
|
"name": "Weapon Critical Hit Damage",
|
||||||
{"name":"Armor","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.66,11.66],"weaponRange":[9.33,23.33]},
|
"unit": "%",
|
||||||
{"name":"Damage Reduction","unit":"%","type":"Defensive","category":"Character","calculationType":"Multiplicative","gearRange":[2,5],"weaponRange":[4,10]},
|
"type": "Offensive",
|
||||||
{"name":"Received Healing","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive","gearRange":[4.8,12],"weaponRange":[9.6,24]},
|
"category": "Weapon",
|
||||||
{"name":"Damage to Elites","unit":"%","type":"Offensive","category":"Character","calculationType":"Additive","gearRange":[2.4,6],"weaponRange":[4.8,12]},
|
"calculationType": "Additive",
|
||||||
{"name":"Signature Ability Charge Efficiency","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]},
|
"gearRange": [4, 10],
|
||||||
{"name":"Skill Duration","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[3.2,8]},
|
"weaponRange": [8, 20]
|
||||||
{"name":"Skill Radius","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[4,10]},
|
},
|
||||||
{"name":"Skill Cooldown Recovery","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","gearRange":[-5,-2]},
|
{
|
||||||
{"name":"Weapon Blast Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]},
|
"name": "Weapon Damage",
|
||||||
{"name":"Weapon Piercing Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]},
|
"unit": "%",
|
||||||
{"name":"Weapon Shredding Damage","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive","weaponRange":[4,10]},
|
"type": "Offensive",
|
||||||
{"name":"Skill Blast Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]},
|
"category": "Weapon",
|
||||||
{"name":"Skill Piercing Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]},
|
"calculationType": "Additive",
|
||||||
{"name":"Skill Shredding Damage","unit":"%","type":"Offensive","category":"Skill","calculationType":"Additive","weaponRange":[4,10]},
|
"gearRange": [2, 5],
|
||||||
{"name":"Rate of Fire","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"weaponRange": [4, 10]
|
||||||
{"name":"Reload Speed","unit":"%","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
},
|
||||||
{"name":"Health","unit":"","type":"Defensive","category":"Character","calculationType":"Additive"},
|
{
|
||||||
{"name":"Armor","unit":"","type":"Defensive","category":"Character","calculationType":"Additive"},
|
"name": "Weapon Multi-Shot Chance",
|
||||||
{"name":"Movement Speed","unit":"%","type":"Defensive","category":"Character","calculationType":"Additive"},
|
"unit": "%",
|
||||||
{"name":"Piercing Damage","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"type": "Offensive",
|
||||||
{"name":"RPM","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"category": "Weapon",
|
||||||
{"name":"Optimal Range","unit":"m","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"calculationType": "Additive",
|
||||||
{"name":"ADS Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"gearRange": [2, 5],
|
||||||
{"name":"Reload Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"weaponRange": [4, 10]
|
||||||
{"name":"Magazine Size","unit":"Shots","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
},
|
||||||
{"name":"Handling Speed","unit":"s","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
{
|
||||||
{"name":"Accuracy","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"},
|
"name": "Skill Critical Chance",
|
||||||
{"name":"Vertical Stability","unit":"","type":"Offensive","category":"Weapon","calculationType":"Additive"}
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2.4, 6]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Critical Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10],
|
||||||
|
"weaponRange": [8, 20]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Intensity",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Multi-Shot Chance",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Headshot Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4.8, 12],
|
||||||
|
"weaponRange": [9.6, 24]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Max Health",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10],
|
||||||
|
"weaponRange": [8, 20]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Armor",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4.66, 11.66],
|
||||||
|
"weaponRange": [9.33, 23.33]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Damage Reduction",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Multiplicative",
|
||||||
|
"gearRange": [2, 5],
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Received Healing",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4.8, 12],
|
||||||
|
"weaponRange": [9.6, 24]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Damage to Elites",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [2.4, 6],
|
||||||
|
"weaponRange": [4.8, 12]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Signature Ability Charge Efficiency",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Duration",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [3.2, 8]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Radius",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Cooldown Recovery",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"gearRange": [-5, -2]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Blast Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Piercing Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weapon Shredding Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Blast Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Piercing Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skill Shredding Damage",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Skill",
|
||||||
|
"calculationType": "Additive",
|
||||||
|
"weaponRange": [4, 10]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rate of Fire",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reload Speed",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Health",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Armor",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Movement Speed",
|
||||||
|
"unit": "%",
|
||||||
|
"type": "Defensive",
|
||||||
|
"category": "Character",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Piercing Damage",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RPM",
|
||||||
|
"unit": "Shots",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Optimal Range",
|
||||||
|
"unit": "m",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ADS Speed",
|
||||||
|
"unit": "s",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reload Speed",
|
||||||
|
"unit": "s",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magazine Size",
|
||||||
|
"unit": "Shots",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Handling Speed",
|
||||||
|
"unit": "s",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accuracy",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vertical Stability",
|
||||||
|
"unit": "",
|
||||||
|
"type": "Offensive",
|
||||||
|
"category": "Weapon",
|
||||||
|
"calculationType": "Additive"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+25
-17
@@ -3,19 +3,25 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|||||||
import { basename, dirname, resolve } from 'node:path';
|
import { basename, dirname, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
const requestedOutput = process.argv[2];
|
const requestedOutput = process.argv[2];
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
|
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
|
||||||
const outputPath = resolve(
|
const outputPath = resolve(
|
||||||
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`
|
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`,
|
||||||
);
|
);
|
||||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const tableRows = await (
|
const tableRows = await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT name
|
SELECT name
|
||||||
FROM sqlite_master
|
FROM sqlite_master
|
||||||
WHERE type = 'table'
|
WHERE type = 'table'
|
||||||
@@ -27,23 +33,25 @@ const tableRows = await (
|
|||||||
const tables = [];
|
const tables = [];
|
||||||
let totalRows = 0;
|
let totalRows = 0;
|
||||||
for (const tableRow of tableRows) {
|
for (const tableRow of tableRows) {
|
||||||
const name = String(tableRow.name);
|
const name = String(tableRow.name);
|
||||||
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
|
const rows = await (
|
||||||
tables.push({ name, rowCount: rows.length, rows });
|
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||||
totalRows += rows.length;
|
).all();
|
||||||
|
tables.push({ name, rowCount: rows.length, rows });
|
||||||
|
totalRows += rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = {
|
const backup = {
|
||||||
format: 'shd-build-database-backup',
|
format: 'shd-build-database-backup',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
tables
|
tables,
|
||||||
};
|
};
|
||||||
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
flag: 'wx'
|
flag: 'wx',
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
||||||
|
|||||||
+2714
-1189
File diff suppressed because it is too large
Load Diff
+111
-30
@@ -5,53 +5,134 @@ import process from 'node:process';
|
|||||||
|
|
||||||
const backupArgument = process.argv[2];
|
const backupArgument = process.argv[2];
|
||||||
if (!backupArgument) {
|
if (!backupArgument) {
|
||||||
console.error('Usage: npm run db:restore -- <backup-file>');
|
console.error('Usage: npm run db:restore -- <backup-file>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupPath = resolve(backupArgument);
|
const backupPath = resolve(backupArgument);
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
|
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
|
||||||
if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build database backup.');
|
backup?.format !== 'shd-build-database-backup' ||
|
||||||
|
backup?.version !== 1 ||
|
||||||
|
!Array.isArray(backup.tables)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build database backup.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
|
|
||||||
// Restores are deliberately limited to empty databases. This prevents an
|
// Restores are deliberately limited to empty databases. This prevents an
|
||||||
// accidental merge from overwriting contributor-entered records.
|
// accidental merge from overwriting contributor-entered records.
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
const existing = await (
|
const existing = await (
|
||||||
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
|
await database.prepare(
|
||||||
).get();
|
`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`,
|
||||||
if (Number(existing?.count) > 0) {
|
)
|
||||||
throw new Error(
|
).get();
|
||||||
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
|
if (Number(existing?.count) > 0) {
|
||||||
);
|
throw new Error(
|
||||||
}
|
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let restoredRows = 0;
|
let restoredRows = 0;
|
||||||
|
const attributeTable = backup.tables.find(
|
||||||
|
(table) => table.name === 'stat_attributes',
|
||||||
|
);
|
||||||
|
const legacySkillChipProfiles = attributeTable?.rows.some(
|
||||||
|
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
|
||||||
|
);
|
||||||
|
const legacySkillChipRanges = new Map(
|
||||||
|
(attributeTable?.rows ?? []).map((row) => [
|
||||||
|
String(row.id),
|
||||||
|
{ min: row.skill_chip_min, max: row.skill_chip_max },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const restoredLegacySecondaries = new Set();
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const table of backup.tables) {
|
for (const table of backup.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
if (
|
||||||
if (!columns.length) continue;
|
legacySkillChipProfiles &&
|
||||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
table.name === 'skill_chip_primary_options'
|
||||||
const placeholders = columns.map(() => '?').join(', ');
|
)
|
||||||
const insert = await database.prepare(
|
continue;
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`
|
const restoredRow = { ...row };
|
||||||
);
|
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
|
||||||
await insert.run(...columns.map((column) => row[column]));
|
delete restoredRow.skill_chip_min;
|
||||||
restoredRows++;
|
delete restoredRow.skill_chip_max;
|
||||||
}
|
}
|
||||||
}
|
if (
|
||||||
await database.exec('COMMIT');
|
legacySkillChipProfiles &&
|
||||||
|
table.name === 'skill_chip_secondary_options'
|
||||||
|
) {
|
||||||
|
delete restoredRow.skill_chip_set_id;
|
||||||
|
const statId = String(restoredRow.stat_attribute_id ?? '');
|
||||||
|
if (restoredLegacySecondaries.has(statId)) continue;
|
||||||
|
restoredLegacySecondaries.add(statId);
|
||||||
|
}
|
||||||
|
const columns = Object.keys(restoredRow);
|
||||||
|
if (!columns.length) continue;
|
||||||
|
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||||
|
const placeholders = columns.map(() => '?').join(', ');
|
||||||
|
const insert = await database.prepare(
|
||||||
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
|
||||||
|
);
|
||||||
|
await insert.run(...columns.map((column) => restoredRow[column]));
|
||||||
|
restoredRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (legacySkillChipProfiles) {
|
||||||
|
const primaryAttributes = await (
|
||||||
|
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
|
||||||
|
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
|
||||||
|
)`)
|
||||||
|
).all();
|
||||||
|
if (
|
||||||
|
primaryAttributes.length !== 4 ||
|
||||||
|
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
|
||||||
|
);
|
||||||
|
for (const attribute of primaryAttributes) {
|
||||||
|
const statId = String(attribute.id);
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
|
||||||
|
)
|
||||||
|
).run(statId);
|
||||||
|
const range = legacySkillChipRanges.get(statId);
|
||||||
|
if (!range || range.min === null || range.max === null) continue;
|
||||||
|
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
|
||||||
|
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||||
|
).run(statId, quality, range.min, range.max);
|
||||||
|
}
|
||||||
|
for (const statId of restoredLegacySecondaries) {
|
||||||
|
const range = legacySkillChipRanges.get(statId);
|
||||||
|
if (!range || range.min === null || range.max === null) continue;
|
||||||
|
for (const quality of ['Specialized', 'Superior', 'High-end'])
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
|
||||||
|
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
|
||||||
|
).run(statId, quality, range.min, range.max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
||||||
|
|||||||
+87
-70
@@ -2,30 +2,30 @@ import { argon2id, hash } from 'argon2';
|
|||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
export const defaultUsers = [
|
export const defaultUsers = [
|
||||||
{
|
{
|
||||||
username: 'Aewyn',
|
username: 'Aewyn',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
|
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'Quartermaster',
|
username: 'Quartermaster',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
|
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function temporaryPassword() {
|
function temporaryPassword() {
|
||||||
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
||||||
return randomBytes(15).toString('base64url');
|
return randomBytes(15).toString('base64url');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function secureHash(password) {
|
async function secureHash(password) {
|
||||||
return hash(password, {
|
return hash(password, {
|
||||||
type: argon2id,
|
type: argon2id,
|
||||||
memoryCost: 19_456,
|
memoryCost: 19_456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
parallelism: 1
|
parallelism: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,61 +33,78 @@ async function secureHash(password) {
|
|||||||
* users keep their IDs and related records but receive new password hashes.
|
* users keep their IDs and related records but receive new password hashes.
|
||||||
*/
|
*/
|
||||||
export async function initializeDefaultUsers(database, options = {}) {
|
export async function initializeDefaultUsers(database, options = {}) {
|
||||||
const resetPasswords = options.resetPasswords === true;
|
const resetPasswords = options.resetPasswords === true;
|
||||||
const findUser = await database.prepare(
|
const findUser = await database.prepare(
|
||||||
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE'
|
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE',
|
||||||
);
|
);
|
||||||
const insertUser = await database.prepare(
|
const insertUser = await database.prepare(
|
||||||
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
);
|
);
|
||||||
const updateCredential = await database.prepare(
|
const updateCredential = await database.prepare(
|
||||||
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?'
|
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?',
|
||||||
);
|
);
|
||||||
const credentials = [];
|
const credentials = [];
|
||||||
|
|
||||||
for (const definition of defaultUsers) {
|
for (const definition of defaultUsers) {
|
||||||
const existing = await findUser.get(definition.username, definition.username);
|
const existing = await findUser.get(
|
||||||
if (existing && !resetPasswords) {
|
definition.username,
|
||||||
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
|
definition.username,
|
||||||
continue;
|
);
|
||||||
}
|
if (existing && !resetPasswords) {
|
||||||
|
console.log(
|
||||||
|
`${definition.username}: already exists (${existing.role}); left unchanged`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
|
const configuredPassword =
|
||||||
const password = configuredPassword || temporaryPassword();
|
process.env[definition.passwordEnvironmentKey]?.trim();
|
||||||
if (password.length < 12 || password.length > 128) {
|
const password = configuredPassword || temporaryPassword();
|
||||||
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
|
if (password.length < 12 || password.length > 128) {
|
||||||
}
|
throw new Error(
|
||||||
const passwordHash = await secureHash(password);
|
`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordHash = await secureHash(password);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id);
|
await updateCredential.run(
|
||||||
console.log(`${definition.username}: credentials reset; role and related data preserved`);
|
definition.username,
|
||||||
} else {
|
passwordHash,
|
||||||
await insertUser.run(
|
String(existing.role),
|
||||||
randomUUID(),
|
existing.id,
|
||||||
definition.username,
|
);
|
||||||
definition.username,
|
console.log(
|
||||||
passwordHash,
|
`${definition.username}: credentials reset; role and related data preserved`,
|
||||||
definition.role,
|
);
|
||||||
new Date().toISOString()
|
} else {
|
||||||
);
|
await insertUser.run(
|
||||||
console.log(`${definition.username}: default account created`);
|
randomUUID(),
|
||||||
}
|
definition.username,
|
||||||
credentials.push({
|
definition.username,
|
||||||
username: definition.username,
|
passwordHash,
|
||||||
role: existing ? String(existing.role) : definition.role,
|
definition.role,
|
||||||
password,
|
new Date().toISOString(),
|
||||||
generated: !configuredPassword
|
);
|
||||||
});
|
console.log(`${definition.username}: default account created`);
|
||||||
}
|
}
|
||||||
|
credentials.push({
|
||||||
|
username: definition.username,
|
||||||
|
role: existing ? String(existing.role) : definition.role,
|
||||||
|
password,
|
||||||
|
generated: !configuredPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (credentials.length) {
|
if (credentials.length) {
|
||||||
console.log('\nDefault user credentials (shown once):');
|
console.log('\nDefault user credentials (shown once):');
|
||||||
for (const credential of credentials) {
|
for (const credential of credentials) {
|
||||||
console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`);
|
console.log(
|
||||||
}
|
`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`,
|
||||||
console.log('Change generated passwords after the first sign-in.\n');
|
);
|
||||||
}
|
}
|
||||||
|
console.log('Change generated passwords after the first sign-in.\n');
|
||||||
|
}
|
||||||
|
|
||||||
return credentials;
|
return credentials;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import process from 'node:process';
|
|
||||||
|
|
||||||
const files = ['.env.development', '.env.staging', '.env.production'];
|
|
||||||
const action = process.argv[2];
|
|
||||||
const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git';
|
|
||||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
|
||||||
|
|
||||||
function run(command, args) {
|
|
||||||
const result = spawnSync(command, args, { cwd: process.cwd(), stdio: 'inherit' });
|
|
||||||
if (result.error) throw result.error;
|
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dotenvx(command, file) {
|
|
||||||
run(process.execPath, [dotenvxCli, command, '-f', file]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['encrypt', 'decrypt', 'stage'].includes(action)) {
|
|
||||||
console.error('Usage: node scripts/env-files.mjs <encrypt|decrypt|stage>');
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'stage') {
|
|
||||||
run(gitCommand, ['add', '--', ...files]);
|
|
||||||
console.log('Encrypted and staged environment files.');
|
|
||||||
}
|
|
||||||
@@ -5,50 +5,81 @@ import process from 'node:process';
|
|||||||
|
|
||||||
const target = process.argv[2];
|
const target = process.argv[2];
|
||||||
if (!['staging', 'production'].includes(target)) {
|
if (!['staging', 'production'].includes(target)) {
|
||||||
console.error('Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]');
|
console.error(
|
||||||
process.exit(2);
|
'Usage: node scripts/environment-bootstrap-export.mjs <staging|production> [output.json]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const outputArgument = process.argv[3];
|
const outputArgument = process.argv[3];
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
const outputPath = resolve(outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`);
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
outputArgument || `./data/backups/shd-${target}-bootstrap-${timestamp}.json`,
|
||||||
|
);
|
||||||
const temporaryBackupPath = `${outputPath}.database.json`;
|
const temporaryBackupPath = `${outputPath}.database.json`;
|
||||||
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
|
const databaseBackup = spawnSync(
|
||||||
cwd: process.cwd(),
|
process.execPath,
|
||||||
stdio: 'inherit',
|
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||||
env: process.env
|
{
|
||||||
});
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (databaseBackup.error) throw databaseBackup.error;
|
if (databaseBackup.error) throw databaseBackup.error;
|
||||||
if (databaseBackup.status !== 0) throw new Error(`Database backup failed; no ${target} bootstrap bundle was created.`);
|
if (databaseBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`Database backup failed; no ${target} bootstrap bundle was created.`,
|
||||||
|
);
|
||||||
|
|
||||||
async function collectFiles(directory, pattern) {
|
async function collectFiles(directory, pattern) {
|
||||||
try {
|
try {
|
||||||
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
|
const names = (await readdir(directory))
|
||||||
return await Promise.all(names.map(async (name) => ({
|
.filter((name) => pattern.test(name))
|
||||||
name,
|
.sort();
|
||||||
contentBase64: (await readFile(join(directory, name))).toString('base64')
|
return await Promise.all(
|
||||||
})));
|
names.map(async (name) => ({
|
||||||
} catch (error) {
|
name,
|
||||||
if (error?.code === 'ENOENT') return [];
|
contentBase64: (await readFile(join(directory, name))).toString(
|
||||||
throw error;
|
'base64',
|
||||||
}
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
||||||
const avatars = await collectFiles(resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'), /^[0-9a-f-]{36}\.webp$/i);
|
const avatars = await collectFiles(
|
||||||
const gameIcons = await collectFiles(resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), /^[a-z_]+-[0-9a-f-]{36}\.webp$/i);
|
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
||||||
const bundle = {
|
/^[0-9a-f-]{36}\.webp$/i,
|
||||||
format: 'shd-build-environment-bootstrap',
|
);
|
||||||
version: 1,
|
const gameIcons = await collectFiles(
|
||||||
target,
|
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
||||||
createdAt: new Date().toISOString(),
|
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
|
||||||
database,
|
);
|
||||||
files: { avatars, gameIcons }
|
const bundle = {
|
||||||
};
|
format: 'shd-build-environment-bootstrap',
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
version: 1,
|
||||||
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
target,
|
||||||
console.log(`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`);
|
createdAt: new Date().toISOString(),
|
||||||
console.log(`Bootstrap bundle: ${outputPath}`);
|
database,
|
||||||
|
files: { avatars, gameIcons },
|
||||||
|
};
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${target} bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} managed image assets.`,
|
||||||
|
);
|
||||||
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(temporaryBackupPath, { force: true });
|
await rm(temporaryBackupPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,112 +4,178 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
const bundleArgument = process.argv
|
||||||
|
.slice(2)
|
||||||
|
.find((argument) => !argument.startsWith('--'));
|
||||||
const targetIndex = process.argv.indexOf('--target');
|
const targetIndex = process.argv.indexOf('--target');
|
||||||
const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : '';
|
const target = targetIndex >= 0 ? process.argv[targetIndex + 1] : '';
|
||||||
const allowed = target === 'staging'
|
const allowed =
|
||||||
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
|
target === 'staging'
|
||||||
: target === 'production'
|
? process.env.ALLOW_STAGING_BOOTSTRAP === 'true'
|
||||||
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
|
: target === 'production'
|
||||||
: false;
|
? process.env.ALLOW_PRODUCTION_BOOTSTRAP === 'true'
|
||||||
if (!bundleArgument || !process.argv.includes('--confirm-empty-target-replace') || !allowed) {
|
: false;
|
||||||
console.error('Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:');
|
if (
|
||||||
console.error('node scripts/environment-bootstrap-import.mjs <bundle.json> --target <staging|production> --confirm-empty-target-replace');
|
!bundleArgument ||
|
||||||
process.exit(2);
|
!process.argv.includes('--confirm-empty-target-replace') ||
|
||||||
|
!allowed
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Environment bootstrap requires the matching ALLOW_*_BOOTSTRAP=true guard and:',
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'node scripts/environment-bootstrap-import.mjs <bundle.json> --target <staging|production> --confirm-empty-target-replace',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundlePath = resolve(bundleArgument);
|
const bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (bundle?.format !== 'shd-build-environment-bootstrap' || bundle?.version !== 1 || bundle?.target !== target
|
if (
|
||||||
|| bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1
|
bundle?.format !== 'shd-build-environment-bootstrap' ||
|
||||||
|| !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars)
|
bundle?.version !== 1 ||
|
||||||
|| !Array.isArray(bundle.files?.gameIcons)) {
|
bundle?.target !== target ||
|
||||||
throw new Error('The selected file is not a supported SHD.build bootstrap bundle for the selected target.');
|
bundle.database?.format !== 'shd-build-database-backup' ||
|
||||||
|
bundle.database?.version !== 1 ||
|
||||||
|
!Array.isArray(bundle.database.tables) ||
|
||||||
|
!Array.isArray(bundle.files?.avatars) ||
|
||||||
|
!Array.isArray(bundle.files?.gameIcons)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build bootstrap bundle for the selected target.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const file of bundle.files.avatars) {
|
for (const file of bundle.files.avatars) {
|
||||||
if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
|
if (
|
||||||
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
typeof file.name !== 'string' ||
|
||||||
}
|
!/^[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const file of bundle.files.gameIcons) {
|
for (const file of bundle.files.gameIcons) {
|
||||||
if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
|
if (
|
||||||
throw new Error('The bootstrap bundle contains an invalid game-data icon file.');
|
typeof file.name !== 'string' ||
|
||||||
}
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The bootstrap bundle contains an invalid game-data icon file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || `./data/shd-build-${target}.db`);
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || `./data/shd-build-${target}.db`,
|
||||||
|
);
|
||||||
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env
|
env: process.env,
|
||||||
});
|
});
|
||||||
if (safetyBackup.error) throw safetyBackup.error;
|
if (safetyBackup.error) throw safetyBackup.error;
|
||||||
if (safetyBackup.status !== 0) throw new Error(`${target} safety backup failed; the target was not changed.`);
|
if (safetyBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`${target} safety backup failed; the target was not changed.`,
|
||||||
|
);
|
||||||
|
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) {
|
for (const tableName of [
|
||||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get();
|
'users',
|
||||||
if (Number(row?.count ?? 0) > 0) {
|
'builds',
|
||||||
throw new Error(`${target} bootstrap refused: target table "${tableName}" is not empty.`);
|
'audit_log',
|
||||||
}
|
'data_change_proposals',
|
||||||
|
]) {
|
||||||
|
const row = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)
|
||||||
|
).get();
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${target} bootstrap refused: target table "${tableName}" is not empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTables = await (await database.prepare(`
|
const targetTables = await (
|
||||||
|
await database.prepare(`
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
ORDER BY rowid
|
ORDER BY rowid
|
||||||
`)).all();
|
`)
|
||||||
|
).all();
|
||||||
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
||||||
const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name)));
|
const sourceNames = new Set(
|
||||||
|
bundle.database.tables.map((table) => String(table.name)),
|
||||||
|
);
|
||||||
for (const row of targetTables) {
|
for (const row of targetTables) {
|
||||||
if (!sourceNames.has(String(row.name))) {
|
if (!sourceNames.has(String(row.name))) {
|
||||||
throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`);
|
throw new Error(
|
||||||
}
|
`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sourceOnlyTables = bundle.database.tables
|
const sourceOnlyTables = bundle.database.tables
|
||||||
.map((table) => String(table.name))
|
.map((table) => String(table.name))
|
||||||
.filter((name) => !targetNames.has(name));
|
.filter((name) => !targetNames.has(name));
|
||||||
if (sourceOnlyTables.length) {
|
if (sourceOnlyTables.length) {
|
||||||
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
|
throw new Error(
|
||||||
|
`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
let restoredRows = 0;
|
let restoredRows = 0;
|
||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const row of [...targetTables].reverse()) {
|
for (const row of [...targetTables].reverse()) {
|
||||||
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
||||||
}
|
}
|
||||||
for (const table of bundle.database.tables) {
|
for (const table of bundle.database.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
VALUES (${columns.map(() => '?').join(', ')})`
|
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||||
);
|
);
|
||||||
await insert.run(...columns.map((column) => row[column]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
restoredRows++;
|
restoredRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars');
|
const avatarDirectory = resolve(
|
||||||
const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars',
|
||||||
|
);
|
||||||
|
const gameIconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
await mkdir(avatarDirectory, { recursive: true });
|
await mkdir(avatarDirectory, { recursive: true });
|
||||||
await mkdir(gameIconDirectory, { recursive: true });
|
await mkdir(gameIconDirectory, { recursive: true });
|
||||||
for (const file of bundle.files.avatars) {
|
for (const file of bundle.files.avatars) {
|
||||||
await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(
|
||||||
|
join(avatarDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const file of bundle.files.gameIcons) {
|
for (const file of bundle.files.gameIcons) {
|
||||||
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(
|
||||||
|
join(gameIconDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`);
|
console.log(
|
||||||
|
`Bootstrapped ${target} with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} managed image assets.`,
|
||||||
|
);
|
||||||
console.log(`Source bundle: ${bundlePath}`);
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
|
|||||||
@@ -4,121 +4,180 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const sourcePath = resolve(process.argv[2] || './scripts/data/resurgence-attributes.json');
|
const sourcePath = resolve(
|
||||||
|
process.argv[2] || './scripts/data/resurgence-attributes.json',
|
||||||
|
);
|
||||||
const records = JSON.parse(await readFile(sourcePath, 'utf8'));
|
const records = JSON.parse(await readFile(sourcePath, 'utf8'));
|
||||||
if (!Array.isArray(records) || !records.length) throw new Error('The Attribute import source is empty or invalid.');
|
if (!Array.isArray(records) || !records.length)
|
||||||
|
throw new Error('The Attribute import source is empty or invalid.');
|
||||||
|
|
||||||
const identities = new Set();
|
const identities = new Set();
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`;
|
const identity = `${String(record.name).normalize('NFKC').trim().toLocaleLowerCase()}\u0000${String(record.unit ?? '')}`;
|
||||||
if (identities.has(identity)) throw new Error(`Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`);
|
if (identities.has(identity))
|
||||||
identities.add(identity);
|
throw new Error(
|
||||||
if (!['Offensive', 'Defensive'].includes(record.type)
|
`Duplicate Attribute identity in import source: ${record.name} (${record.unit || 'flat'})`,
|
||||||
|| !['Weapon', 'Skill', 'Character'].includes(record.category)
|
);
|
||||||
|| !['Additive', 'Multiplicative'].includes(record.calculationType)
|
identities.add(identity);
|
||||||
|| !['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')) {
|
if (
|
||||||
throw new Error(`Invalid Attribute classification in import source: ${record.name}`);
|
!['Offensive', 'Defensive'].includes(record.type) ||
|
||||||
}
|
!['Weapon', 'Skill', 'Character'].includes(record.category) ||
|
||||||
for (const range of [record.gearRange, record.weaponRange]) {
|
!['Additive', 'Multiplicative'].includes(record.calculationType) ||
|
||||||
if (range === undefined) continue;
|
!['', '%', 's', 'Shots', 'm'].includes(record.unit ?? '')
|
||||||
if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] > range[1]) {
|
) {
|
||||||
throw new Error(`Invalid source range in import source: ${record.name}`);
|
throw new Error(
|
||||||
}
|
`Invalid Attribute classification in import source: ${record.name}`,
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
for (const range of [record.gearRange, record.weaponRange]) {
|
||||||
|
if (range === undefined) continue;
|
||||||
|
if (
|
||||||
|
!Array.isArray(range) ||
|
||||||
|
range.length !== 2 ||
|
||||||
|
!range.every(Number.isFinite) ||
|
||||||
|
range[0] > range[1]
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid source range in import source: ${record.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
|
const vite = await createServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: 'custom',
|
||||||
|
logLevel: 'error',
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const { getDatabase } = await vite.ssrLoadModule('/src/lib/server/database.ts');
|
const { getDatabase } = await vite.ssrLoadModule(
|
||||||
const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule('/src/lib/server/audit.ts');
|
'/src/lib/server/database.ts',
|
||||||
const database = await getDatabase();
|
);
|
||||||
const actor = await (await database.prepare(`SELECT users.id FROM users
|
const { auditRecord, recordIsacAudit } = await vite.ssrLoadModule(
|
||||||
|
'/src/lib/server/audit.ts',
|
||||||
|
);
|
||||||
|
const database = await getDatabase();
|
||||||
|
const actor = await (
|
||||||
|
await database.prepare(`SELECT users.id FROM users
|
||||||
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||||
LEFT JOIN user_roles link ON link.user_id=users.id
|
LEFT JOIN user_roles link ON link.user_id=users.id
|
||||||
WHERE users.role='admin' OR link.role_id='role-admin'
|
WHERE users.role='admin' OR link.role_id='role-admin'
|
||||||
ORDER BY CASE WHEN users.username=? COLLATE NOCASE OR profile.display_name=? COLLATE NOCASE THEN 0 ELSE 1 END,users.created_at
|
ORDER BY CASE WHEN users.username=? COLLATE NOCASE OR profile.display_name=? COLLATE NOCASE THEN 0 ELSE 1 END,users.created_at
|
||||||
LIMIT 1`)).get('Aewyn', 'Aewyn');
|
LIMIT 1`)
|
||||||
if (!actor?.id) throw new Error('No administrator account is available to own the Attribute import audit events.');
|
).get('Aewyn', 'Aewyn');
|
||||||
|
if (!actor?.id)
|
||||||
|
throw new Error(
|
||||||
|
'No administrator account is available to own the Attribute import audit events.',
|
||||||
|
);
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let unchanged = 0;
|
let unchanged = 0;
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const name = String(record.name).normalize('NFKC').trim();
|
const name = String(record.name).normalize('NFKC').trim();
|
||||||
const unit = String(record.unit ?? '');
|
const unit = String(record.unit ?? '');
|
||||||
const existing = await (await database.prepare(
|
const existing = await (
|
||||||
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?'
|
await database.prepare(
|
||||||
)).get(name, unit);
|
'SELECT * FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?',
|
||||||
const id = String(existing?.id ?? randomUUID());
|
)
|
||||||
const now = new Date().toISOString();
|
).get(name, unit);
|
||||||
const next = {
|
const id = String(existing?.id ?? randomUUID());
|
||||||
...(existing ?? {}),
|
const now = new Date().toISOString();
|
||||||
id,
|
const next = {
|
||||||
name,
|
...(existing ?? {}),
|
||||||
description: existing?.description ?? null,
|
id,
|
||||||
type: record.type,
|
name,
|
||||||
category: record.category,
|
description: existing?.description ?? null,
|
||||||
calculation_type: record.calculationType,
|
type: record.type,
|
||||||
unit,
|
category: record.category,
|
||||||
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
|
calculation_type: record.calculationType,
|
||||||
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
|
unit,
|
||||||
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
|
gear_min: record.gearRange?.[0] ?? existing?.gear_min ?? null,
|
||||||
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
gear_max: record.gearRange?.[1] ?? existing?.gear_max ?? null,
|
||||||
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
gear_reference_level: Number(existing?.gear_reference_level ?? 50),
|
||||||
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
|
||||||
skill_chip_min: existing?.skill_chip_min ?? null,
|
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
|
||||||
skill_chip_max: existing?.skill_chip_max ?? null,
|
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
|
||||||
cap_value: existing?.cap_value ?? null,
|
cap_value: existing?.cap_value ?? null,
|
||||||
active: 1,
|
active: 1,
|
||||||
created_at: String(existing?.created_at ?? now),
|
created_at: String(existing?.created_at ?? now),
|
||||||
updated_at: now
|
updated_at: now,
|
||||||
};
|
};
|
||||||
const comparableExisting = existing ? { ...existing, updated_at: now } : null;
|
const comparableExisting = existing
|
||||||
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
|
? { ...existing, updated_at: now }
|
||||||
unchanged++;
|
: null;
|
||||||
continue;
|
if (comparableExisting && isDeepStrictEqual(comparableExisting, next)) {
|
||||||
}
|
unchanged++;
|
||||||
const before = existing ? await auditRecord('stat_attributes', id) : null;
|
continue;
|
||||||
if (existing) {
|
}
|
||||||
await (await database.prepare(`UPDATE stat_attributes SET
|
const before = existing ? await auditRecord('stat_attributes', id) : null;
|
||||||
|
if (existing) {
|
||||||
|
await (
|
||||||
|
await database.prepare(`UPDATE stat_attributes SET
|
||||||
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
|
||||||
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
|
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
|
||||||
cap_value=?,active=1,updated_at=? WHERE id=?`)).run(
|
cap_value=?,active=1,updated_at=? WHERE id=?`)
|
||||||
next.name, next.description, next.type, next.category, next.calculation_type, next.unit,
|
).run(
|
||||||
next.gear_min, next.gear_max, next.weapon_min, next.weapon_max, next.skill_chip_min,
|
next.name,
|
||||||
next.skill_chip_max, next.cap_value, now, id
|
next.description,
|
||||||
);
|
next.type,
|
||||||
updated++;
|
next.category,
|
||||||
} else {
|
next.calculation_type,
|
||||||
await (await database.prepare(`INSERT INTO stat_attributes(
|
next.unit,
|
||||||
|
next.gear_min,
|
||||||
|
next.gear_max,
|
||||||
|
next.weapon_min,
|
||||||
|
next.weapon_max,
|
||||||
|
next.cap_value,
|
||||||
|
now,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO stat_attributes(
|
||||||
id,name,description,type,category,calculation_type,unit,
|
id,name,description,type,category,calculation_type,unit,
|
||||||
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
|
||||||
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
|
cap_value,active,created_at,updated_at
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)).run(
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
|
||||||
id, next.name, next.description, next.type, next.category, next.calculation_type, next.unit,
|
).run(
|
||||||
next.gear_min, next.gear_max, next.gear_reference_level, next.weapon_min, next.weapon_max,
|
id,
|
||||||
next.weapon_reference_level, next.skill_chip_min, next.skill_chip_max, next.cap_value, now, now
|
next.name,
|
||||||
);
|
next.description,
|
||||||
created++;
|
next.type,
|
||||||
}
|
next.category,
|
||||||
await recordIsacAudit({
|
next.calculation_type,
|
||||||
actorId: String(actor.id),
|
next.unit,
|
||||||
category: 'game_data',
|
next.gear_min,
|
||||||
action: existing ? 'attribute.updated' : 'attribute.created',
|
next.gear_max,
|
||||||
entityId: id,
|
next.gear_reference_level,
|
||||||
before,
|
next.weapon_min,
|
||||||
after: await auditRecord('stat_attributes', id)
|
next.weapon_max,
|
||||||
});
|
next.weapon_reference_level,
|
||||||
}
|
next.cap_value,
|
||||||
await database.exec('COMMIT');
|
now,
|
||||||
} catch (error) {
|
now,
|
||||||
await database.exec('ROLLBACK');
|
);
|
||||||
throw error;
|
created++;
|
||||||
}
|
}
|
||||||
console.log(`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`);
|
await recordIsacAudit({
|
||||||
console.log(`Source: ${sourcePath}`);
|
actorId: String(actor.id),
|
||||||
|
category: 'game_data',
|
||||||
|
action: existing ? 'attribute.updated' : 'attribute.created',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: await auditRecord('stat_attributes', id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Imported ${records.length} Attribute definitions: ${created} created, ${updated} updated, ${unchanged} unchanged.`,
|
||||||
|
);
|
||||||
|
console.log(`Source: ${sourcePath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await vite.close();
|
await vite.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,51 +2,89 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
import {
|
||||||
|
ISAC_DATA_FORMAT_VERSION,
|
||||||
|
ISAC_DATA_TABLES,
|
||||||
|
quoteIdentifier,
|
||||||
|
} from './isac-data-tables.mjs';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
);
|
||||||
const outputPath = resolve(process.argv[2] || `./data/backups/isac-data-${timestamp}.json`);
|
const iconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[2] || `./data/backups/isac-data-${timestamp}.json`,
|
||||||
|
);
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const tables = [];
|
const tables = [];
|
||||||
let totalRows = 0;
|
let totalRows = 0;
|
||||||
|
|
||||||
for (const name of ISAC_DATA_TABLES) {
|
for (const name of ISAC_DATA_TABLES) {
|
||||||
const exists = await (await database.prepare(
|
const exists = await (
|
||||||
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?"
|
await database.prepare(
|
||||||
)).get(name);
|
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
if (!exists) throw new Error(`ISAC export requires missing table "${name}". Run the schema migration first.`);
|
)
|
||||||
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
|
).get(name);
|
||||||
tables.push({ name, rowCount: rows.length, rows });
|
if (!exists)
|
||||||
totalRows += rows.length;
|
throw new Error(
|
||||||
|
`ISAC export requires missing table "${name}". Run the schema migration first.`,
|
||||||
|
);
|
||||||
|
const rows = await (
|
||||||
|
await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)
|
||||||
|
).all();
|
||||||
|
tables.push({ name, rowCount: rows.length, rows });
|
||||||
|
totalRows += rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icons = [];
|
const icons = [];
|
||||||
const exportedStorageKeys = new Set();
|
const exportedStorageKeys = new Set();
|
||||||
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
const iconTable = tables.find((table) => table.name === 'game_data_icons');
|
||||||
const managedImageTable = tables.find((table) => table.name === 'managed_images');
|
const managedImageTable = tables.find(
|
||||||
for (const row of [...(iconTable?.rows ?? []), ...(managedImageTable?.rows ?? [])]) {
|
(table) => table.name === 'managed_images',
|
||||||
if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
|
);
|
||||||
throw new Error(`Invalid managed asset storage key "${String(row.storage_key)}".`);
|
for (const row of [
|
||||||
}
|
...(iconTable?.rows ?? []),
|
||||||
if (exportedStorageKeys.has(row.storage_key)) continue;
|
...(managedImageTable?.rows ?? []),
|
||||||
exportedStorageKeys.add(row.storage_key);
|
]) {
|
||||||
const content = await readFile(join(iconDirectory, row.storage_key));
|
if (
|
||||||
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
|
typeof row.storage_key !== 'string' ||
|
||||||
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid managed asset storage key "${String(row.storage_key)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (exportedStorageKeys.has(row.storage_key)) continue;
|
||||||
|
exportedStorageKeys.add(row.storage_key);
|
||||||
|
const content = await readFile(join(iconDirectory, row.storage_key));
|
||||||
|
icons.push({
|
||||||
|
storageKey: row.storage_key,
|
||||||
|
contentBase64: content.toString('base64'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
format: 'shd-build-isac-data',
|
format: 'shd-build-isac-data',
|
||||||
version: 2,
|
version: ISAC_DATA_FORMAT_VERSION,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
totalRows,
|
totalRows,
|
||||||
tables,
|
tables,
|
||||||
icons
|
icons,
|
||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`);
|
console.log(
|
||||||
|
`Exported ${totalRows} ISAC rows and ${icons.length} uploaded image assets.`,
|
||||||
|
);
|
||||||
console.log(`ISAC snapshot: ${outputPath}`);
|
console.log(`ISAC snapshot: ${outputPath}`);
|
||||||
|
|||||||
+321
-44
@@ -3,41 +3,310 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, join, resolve } from 'node:path';
|
import { basename, join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
|
import {
|
||||||
|
ISAC_DATA_FORMAT_VERSION,
|
||||||
|
ISAC_DATA_TABLES,
|
||||||
|
quoteIdentifier,
|
||||||
|
} from './isac-data-tables.mjs';
|
||||||
|
|
||||||
const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
const snapshotArgument = process.argv
|
||||||
if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) {
|
.slice(2)
|
||||||
console.error('Usage: node scripts/isac-data-import.mjs <snapshot.json> --confirm-replace-isac-data');
|
.find((argument) => !argument.startsWith('--'));
|
||||||
process.exit(2);
|
if (
|
||||||
|
!snapshotArgument ||
|
||||||
|
!process.argv.includes('--confirm-replace-isac-data')
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/isac-data-import.mjs <snapshot.json> --confirm-replace-isac-data',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshotPath = resolve(snapshotArgument);
|
const snapshotPath = resolve(snapshotArgument);
|
||||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
|
||||||
if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 2 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.');
|
snapshot?.format !== 'shd-build-isac-data' ||
|
||||||
|
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
|
||||||
|
!Array.isArray(snapshot.tables) ||
|
||||||
|
!Array.isArray(snapshot.icons)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build ISAC data snapshot.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const receivedNames = snapshot.tables.map((table) => table.name);
|
const receivedNames = snapshot.tables.map((table) => table.name);
|
||||||
if (receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)) {
|
if (
|
||||||
throw new Error('The ISAC snapshot table manifest is incomplete or out of order.');
|
receivedNames.length !== ISAC_DATA_TABLES.length ||
|
||||||
|
ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot table manifest is incomplete or out of order.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const abilityRows = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'specialization_abilities',
|
||||||
|
).rows;
|
||||||
|
const abilitiesById = new Map(
|
||||||
|
abilityRows.map((row) => [String(row.id ?? ''), row]),
|
||||||
|
);
|
||||||
|
const skillChipRows = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'skill_chip_sets',
|
||||||
|
).rows;
|
||||||
|
const activeSkillChipCounts = new Map();
|
||||||
|
for (const row of skillChipRows) {
|
||||||
|
const abilityId = String(row.specialization_ability_id ?? '');
|
||||||
|
if (!abilityId || !abilitiesById.has(abilityId)) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(row.active) !== 1) continue;
|
||||||
|
const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1;
|
||||||
|
if (count > 3) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
activeSkillChipCounts.set(abilityId, count);
|
||||||
|
}
|
||||||
|
const skillChipLinks = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'specialization_ability_skill_chip_links',
|
||||||
|
).rows;
|
||||||
|
const linkedAbilityIds = new Set();
|
||||||
|
for (const row of skillChipLinks) {
|
||||||
|
const abilityId = row.ability_id;
|
||||||
|
const compatibleAbilityId = row.compatible_ability_id;
|
||||||
|
if (
|
||||||
|
typeof abilityId !== 'string' ||
|
||||||
|
!abilityId ||
|
||||||
|
typeof compatibleAbilityId !== 'string' ||
|
||||||
|
!compatibleAbilityId
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityId === compatibleAbilityId) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityId >= compatibleAbilityId) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ability = abilitiesById.get(abilityId);
|
||||||
|
const compatibleAbility = abilitiesById.get(compatibleAbilityId);
|
||||||
|
if (!ability || !compatibleAbility) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ability.specialization_id !== compatibleAbility.specialization_id) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
linkedAbilityIds.has(abilityId) ||
|
||||||
|
linkedAbilityIds.has(compatibleAbilityId)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
linkedAbilityIds.add(abilityId);
|
||||||
|
linkedAbilityIds.add(compatibleAbilityId);
|
||||||
|
}
|
||||||
|
for (const kind of ['weapon', 'gear']) {
|
||||||
|
const options = snapshot.tables.find(
|
||||||
|
(table) => table.name === `${kind}_mod_secondary_options`,
|
||||||
|
).rows;
|
||||||
|
const values = snapshot.tables.find(
|
||||||
|
(table) => table.name === `${kind}_mod_secondary_quality_values`,
|
||||||
|
).rows;
|
||||||
|
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||||
|
if (
|
||||||
|
optionIds.some((id) => !id) ||
|
||||||
|
new Set(optionIds).size !== optionIds.length ||
|
||||||
|
(optionIds.length !== 0 &&
|
||||||
|
(optionIds.length < 2 || optionIds.length > 50)) ||
|
||||||
|
values.length !== optionIds.length * 3
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const statId of optionIds) {
|
||||||
|
const profile = values.filter(
|
||||||
|
(row) => String(row.stat_attribute_id) === statId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
profile.length !== 3 ||
|
||||||
|
['Specialized', 'Superior', 'High-end'].some(
|
||||||
|
(quality) => !profile.some((row) => row.quality === quality),
|
||||||
|
) ||
|
||||||
|
profile.some((row) => {
|
||||||
|
const endpoints = [
|
||||||
|
row.level_1_min,
|
||||||
|
row.level_1_max,
|
||||||
|
row.level_20_min,
|
||||||
|
row.level_20_max,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
endpoints.some(
|
||||||
|
(value) => typeof value !== 'number' || !Number.isFinite(value),
|
||||||
|
) ||
|
||||||
|
endpoints[1] < endpoints[0] ||
|
||||||
|
endpoints[2] < endpoints[0] ||
|
||||||
|
endpoints[3] < endpoints[1] ||
|
||||||
|
endpoints[3] < endpoints[2]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
values.some(
|
||||||
|
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [profileName, qualities, requiredOptions] of [
|
||||||
|
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
|
||||||
|
['secondary', ['Specialized', 'Superior', 'High-end'], null],
|
||||||
|
]) {
|
||||||
|
const options = snapshot.tables.find(
|
||||||
|
(table) => table.name === `skill_chip_${profileName}_options`,
|
||||||
|
).rows;
|
||||||
|
const values = snapshot.tables.find(
|
||||||
|
(table) => table.name === `skill_chip_${profileName}_quality_values`,
|
||||||
|
).rows;
|
||||||
|
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
|
||||||
|
const validCardinality = requiredOptions
|
||||||
|
? optionIds.length === requiredOptions
|
||||||
|
: optionIds.length <= 50;
|
||||||
|
if (
|
||||||
|
!validCardinality ||
|
||||||
|
optionIds.some((id) => !id) ||
|
||||||
|
new Set(optionIds).size !== optionIds.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (profileName === 'primary' && optionIds.length) {
|
||||||
|
const attributes = snapshot.tables.find(
|
||||||
|
(table) => table.name === 'stat_attributes',
|
||||||
|
).rows;
|
||||||
|
const expectedIds = attributes
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
Number(row.active) === 1 &&
|
||||||
|
[
|
||||||
|
'Skill Duration',
|
||||||
|
'Skill Radius',
|
||||||
|
'Skill Cooldown Recovery',
|
||||||
|
'Skill Intensity',
|
||||||
|
].includes(String(row.name)),
|
||||||
|
)
|
||||||
|
.map((row) => String(row.id))
|
||||||
|
.sort();
|
||||||
|
if (
|
||||||
|
expectedIds.length !== 4 ||
|
||||||
|
optionIds
|
||||||
|
.slice()
|
||||||
|
.sort()
|
||||||
|
.some((id, index) => id !== expectedIds[index])
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const statId of optionIds) {
|
||||||
|
const rows = values.filter(
|
||||||
|
(row) => String(row.stat_attribute_id ?? '') === statId,
|
||||||
|
);
|
||||||
|
if (!rows.length) continue;
|
||||||
|
if (
|
||||||
|
rows.length !== qualities.length ||
|
||||||
|
qualities.some(
|
||||||
|
(quality) => !rows.some((row) => row.quality === quality),
|
||||||
|
) ||
|
||||||
|
rows.some(
|
||||||
|
(row) =>
|
||||||
|
typeof row.min_value !== 'number' ||
|
||||||
|
!Number.isFinite(row.min_value) ||
|
||||||
|
typeof row.max_value !== 'number' ||
|
||||||
|
!Number.isFinite(row.max_value) ||
|
||||||
|
row.min_value < 0 ||
|
||||||
|
row.max_value < row.min_value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
values.some(
|
||||||
|
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const icon of snapshot.icons) {
|
for (const icon of snapshot.icons) {
|
||||||
if (typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey)
|
if (
|
||||||
|| typeof icon.contentBase64 !== 'string') {
|
typeof icon.storageKey !== 'string' ||
|
||||||
throw new Error('The ISAC snapshot contains an invalid uploaded icon.');
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) ||
|
||||||
}
|
typeof icon.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The ISAC snapshot contains an invalid uploaded icon.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
.toISOString()
|
||||||
const backupPath = resolve(`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`);
|
.replaceAll(':', '-')
|
||||||
const isacBackupPath = resolve(`./data/backups/isac-data-before-import-${timestamp}.json`);
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const backupPath = resolve(
|
||||||
|
`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`,
|
||||||
|
);
|
||||||
|
const isacBackupPath = resolve(
|
||||||
|
`./data/backups/isac-data-before-import-${timestamp}.json`,
|
||||||
|
);
|
||||||
for (const [script, output] of [
|
for (const [script, output] of [
|
||||||
['scripts/db-backup.mjs', backupPath],
|
['scripts/db-backup.mjs', backupPath],
|
||||||
['scripts/isac-data-export.mjs', isacBackupPath]
|
['scripts/isac-data-export.mjs', isacBackupPath],
|
||||||
]) {
|
]) {
|
||||||
const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env });
|
const result = spawnSync(process.execPath, [script, output], {
|
||||||
if (result.error) throw result.error;
|
cwd: process.cwd(),
|
||||||
if (result.status !== 0) throw new Error(`Safety backup failed in ${script}; no ISAC data was changed.`);
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
`Safety backup failed in ${script}; no ISAC data was changed.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
@@ -45,35 +314,43 @@ let importedRows = 0;
|
|||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const name of [...ISAC_DATA_TABLES].reverse()) {
|
for (const name of [...ISAC_DATA_TABLES].reverse()) {
|
||||||
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
|
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
|
||||||
}
|
}
|
||||||
for (const table of snapshot.tables) {
|
for (const table of snapshot.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
VALUES (${columns.map(() => '?').join(', ')})`
|
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||||
);
|
);
|
||||||
await insert.run(...columns.map((column) => row[column]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
importedRows++;
|
importedRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
const iconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
await mkdir(iconDirectory, { recursive: true });
|
await mkdir(iconDirectory, { recursive: true });
|
||||||
for (const icon of snapshot.icons) {
|
for (const icon of snapshot.icons) {
|
||||||
await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(
|
||||||
|
join(iconDirectory, icon.storageKey),
|
||||||
|
Buffer.from(icon.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`);
|
console.log(
|
||||||
|
`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`,
|
||||||
|
);
|
||||||
console.log(`Full pre-import backup: ${backupPath}`);
|
console.log(`Full pre-import backup: ${backupPath}`);
|
||||||
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
|
||||||
|
|||||||
@@ -1,76 +1,82 @@
|
|||||||
export const ISAC_DATA_TABLES = [
|
export const ISAC_DATA_TABLES = [
|
||||||
'stat_attributes',
|
'stat_attributes',
|
||||||
'talents',
|
'talents',
|
||||||
'talent_tiers',
|
'talent_tiers',
|
||||||
'conditional_effect_rules',
|
'conditional_effect_rules',
|
||||||
'conditional_effect_state_pools',
|
'conditional_effect_state_pools',
|
||||||
'conditional_effect_clauses',
|
'conditional_effect_clauses',
|
||||||
'conditional_effect_outcomes',
|
'conditional_effect_outcomes',
|
||||||
'talent_tier_effects',
|
'talent_tier_effects',
|
||||||
'talent_description_templates',
|
'talent_description_templates',
|
||||||
'talent_weapon_scopes',
|
'talent_weapon_scopes',
|
||||||
'weapon_talent_slots',
|
'weapon_talent_slots',
|
||||||
'brand_sets',
|
'brand_sets',
|
||||||
'brand_set_effects',
|
'brand_set_effects',
|
||||||
'gear',
|
'gear',
|
||||||
'gear_brand_sets',
|
'gear_brand_sets',
|
||||||
'gear_talents',
|
'gear_talents',
|
||||||
'gear_tactical_augments',
|
'gear_tactical_augments',
|
||||||
'gear_tactical_augment_costs',
|
'gear_tactical_augment_costs',
|
||||||
'weapons',
|
'weapons',
|
||||||
'core_attribute_tier_ranges',
|
'core_attribute_tier_ranges',
|
||||||
'weapon_tier_ranges',
|
'weapon_tier_ranges',
|
||||||
'weapon_talent_options',
|
'weapon_talent_options',
|
||||||
'weapon_augmentation_effects',
|
'weapon_augmentation_effects',
|
||||||
'weapon_category_attribute_bonuses',
|
'weapon_category_attribute_bonuses',
|
||||||
'os_protocols',
|
'os_protocols',
|
||||||
'os_protocol_primary_effects',
|
'os_protocol_primary_effects',
|
||||||
'os_protocol_secondary_profiles',
|
'os_protocol_secondary_profiles',
|
||||||
'os_protocol_secondary_effects',
|
'os_protocol_secondary_effects',
|
||||||
'os_protocol_talents',
|
'os_protocol_talents',
|
||||||
'os_protocol_talent_effects',
|
'os_protocol_talent_effects',
|
||||||
'skill_chip_sets',
|
'skill_chip_sets',
|
||||||
'skill_chip_two_piece_bonuses',
|
'skill_chip_two_piece_bonuses',
|
||||||
'skill_chip_primary_options',
|
'skill_chip_primary_options',
|
||||||
'skill_chip_secondary_options',
|
'skill_chip_primary_quality_values',
|
||||||
'skill_chip_talents',
|
'skill_chip_secondary_options',
|
||||||
'skill_chip_talent_effects',
|
'skill_chip_secondary_quality_values',
|
||||||
'weapon_mods',
|
'skill_chip_talents',
|
||||||
'weapon_mod_compatible_categories',
|
'skill_chip_talent_effects',
|
||||||
'weapon_mod_slots',
|
'weapon_mods',
|
||||||
'weapon_mod_quality_values',
|
'weapon_mod_compatible_categories',
|
||||||
'weapon_mod_secondary_options',
|
'weapon_mod_slots',
|
||||||
'weapon_mod_secondary_quality_values',
|
'weapon_mod_quality_values',
|
||||||
'weapon_mod_exotic_attributes',
|
'weapon_mod_secondary_options',
|
||||||
'weapon_mod_effects',
|
'weapon_mod_secondary_quality_values',
|
||||||
'weapon_mod_effect_quality_values',
|
'weapon_mod_exotic_attributes',
|
||||||
'gear_mods',
|
'weapon_mod_effects',
|
||||||
'gear_mod_quality_values',
|
'weapon_mod_effect_quality_values',
|
||||||
'gear_mod_secondary_options',
|
'gear_mods',
|
||||||
'gear_mod_secondary_quality_values',
|
'gear_mod_quality_values',
|
||||||
'specialization_progression_config',
|
'gear_mod_secondary_options',
|
||||||
'specialization_rank_cost_profiles',
|
'gear_mod_secondary_quality_values',
|
||||||
'upgrade_resources',
|
'specialization_progression_config',
|
||||||
'specializations',
|
'specialization_rank_cost_profiles',
|
||||||
'specialization_combat_effects',
|
'upgrade_resources',
|
||||||
'specialization_focuses',
|
'specializations',
|
||||||
'specialization_abilities',
|
'specialization_combat_effects',
|
||||||
'specialization_ability_attacks',
|
'specialization_focuses',
|
||||||
'specialization_ability_effects',
|
'specialization_abilities',
|
||||||
'specialization_focus_abilities',
|
'specialization_ability_skill_chip_links',
|
||||||
'specialization_tree_nodes',
|
'specialization_ability_attacks',
|
||||||
'specialization_tree_node_ranks',
|
'specialization_ability_effects',
|
||||||
'specialization_tree_rank_effects',
|
'specialization_focus_abilities',
|
||||||
'specialization_tree_rank_costs',
|
'specialization_tree_nodes',
|
||||||
'specialization_tree_rank_combat_effects',
|
'specialization_tree_node_ranks',
|
||||||
'specialization_tree_rank_combat_modifiers',
|
'specialization_tree_rank_effects',
|
||||||
'calculation_revisions',
|
'specialization_tree_rank_costs',
|
||||||
'calculation_stages',
|
'specialization_tree_rank_combat_effects',
|
||||||
'attribute_calculation_profiles',
|
'specialization_tree_rank_combat_modifiers',
|
||||||
'skill_attribute_calculation_profiles',
|
'calculation_revisions',
|
||||||
'calculation_calibration_cases',
|
'calculation_stages',
|
||||||
'game_data_icons',
|
'attribute_calculation_profiles',
|
||||||
'managed_images'
|
'skill_attribute_calculation_profiles',
|
||||||
|
'calculation_calibration_cases',
|
||||||
|
'game_data_icons',
|
||||||
|
'managed_images',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
export const ISAC_DATA_FORMAT_VERSION = 5;
|
||||||
|
|
||||||
|
export const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
|
|||||||
@@ -2,28 +2,53 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
);
|
||||||
const outputPath = resolve(process.argv[2] || `./data/backups/managed-images-${timestamp}.json`);
|
const storageDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
process.argv[2] || `./data/backups/managed-images-${timestamp}.json`,
|
||||||
|
);
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const rows = await (await database.prepare('SELECT * FROM managed_images ORDER BY created_at')).all();
|
const rows = await (
|
||||||
|
await database.prepare('SELECT * FROM managed_images ORDER BY created_at')
|
||||||
|
).all();
|
||||||
const images = [];
|
const images = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (typeof row.storage_key !== 'string'
|
if (
|
||||||
|| !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
|
typeof row.storage_key !== 'string' ||
|
||||||
throw new Error(`Invalid managed-image storage key "${String(row.storage_key)}".`);
|
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||||
}
|
row.storage_key,
|
||||||
images.push({ row, contentBase64: (await readFile(join(storageDirectory, row.storage_key))).toString('base64') });
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid managed-image storage key "${String(row.storage_key)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
images.push({
|
||||||
|
row,
|
||||||
|
contentBase64: (
|
||||||
|
await readFile(join(storageDirectory, row.storage_key))
|
||||||
|
).toString('base64'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const bundle = {
|
const bundle = {
|
||||||
format: 'shd-build-managed-images',
|
format: 'shd-build-managed-images',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
sourceDatabase: basename(databasePath),
|
sourceDatabase: basename(databasePath),
|
||||||
images
|
images,
|
||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
console.log(`Exported ${images.length} managed image assets.`);
|
console.log(`Exported ${images.length} managed image assets.`);
|
||||||
console.log(`Image bundle: ${outputPath}`);
|
console.log(`Image bundle: ${outputPath}`);
|
||||||
|
|||||||
@@ -4,47 +4,97 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
const bundleArgument = process.argv
|
||||||
|
.slice(2)
|
||||||
|
.find((argument) => !argument.startsWith('--'));
|
||||||
if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
|
||||||
console.error('Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]');
|
console.error(
|
||||||
process.exit(2);
|
'Usage: node scripts/managed-images-import.mjs <bundle.json> --confirm-merge [--update-existing]',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const updateExisting = process.argv.includes('--update-existing');
|
const updateExisting = process.argv.includes('--update-existing');
|
||||||
|
const gameDataRenderSourceTypes = new Set([
|
||||||
|
'weapon_render',
|
||||||
|
'gear_render',
|
||||||
|
'weapon_mod_render',
|
||||||
|
'gear_mod_render',
|
||||||
|
'talent_render',
|
||||||
|
'brand_set_render',
|
||||||
|
'os_protocol_render',
|
||||||
|
'skill_chip_render',
|
||||||
|
'specialization_render',
|
||||||
|
'specialization_ability_render',
|
||||||
|
'specialization_node_render',
|
||||||
|
]);
|
||||||
const bundlePath = resolve(bundleArgument);
|
const bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (bundle?.format !== 'shd-build-managed-images' || bundle?.version !== 1 || !Array.isArray(bundle.images)) {
|
if (
|
||||||
throw new Error('The selected file is not a supported SHD.build managed-image bundle.');
|
bundle?.format !== 'shd-build-managed-images' ||
|
||||||
|
bundle?.version !== 1 ||
|
||||||
|
!Array.isArray(bundle.images)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build managed-image bundle.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const image of bundle.images) {
|
for (const image of bundle.images) {
|
||||||
if (typeof image?.row?.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(image.row.id)
|
if (
|
||||||
|| typeof image.row.storage_key !== 'string'
|
typeof image?.row?.id !== 'string' ||
|
||||||
|| !/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(image.row.storage_key)
|
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
|
||||||
|| typeof image.contentBase64 !== 'string') {
|
typeof image.row.storage_key !== 'string' ||
|
||||||
throw new Error('The bundle contains an invalid managed image.');
|
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
|
||||||
}
|
image.row.storage_key,
|
||||||
const isItemRender = ['weapon_render', 'gear_render'].includes(image.row.source_entity_type);
|
) ||
|
||||||
if (image.row.source_entity_type && (
|
typeof image.contentBase64 !== 'string'
|
||||||
typeof image.row.source_entity_id !== 'string'
|
) {
|
||||||
|| !/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id)
|
throw new Error('The bundle contains an invalid managed image.');
|
||||||
|| (isItemRender
|
}
|
||||||
? !image.row.storage_key.startsWith('library-')
|
const isGameDataRender = gameDataRenderSourceTypes.has(
|
||||||
: !image.row.storage_key.startsWith(`${image.row.source_entity_type}-`))
|
image.row.source_entity_type,
|
||||||
)) throw new Error('The bundle contains an invalid linked ISAC image.');
|
);
|
||||||
|
if (
|
||||||
|
image.row.source_entity_type &&
|
||||||
|
(typeof image.row.source_entity_id !== 'string' ||
|
||||||
|
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
|
||||||
|
(isGameDataRender
|
||||||
|
? !image.row.storage_key.startsWith('library-')
|
||||||
|
: !image.row.storage_key.startsWith(
|
||||||
|
`${image.row.source_entity_type}-`,
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
throw new Error('The bundle contains an invalid linked ISAC image.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
const backup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
cwd: process.cwd(), stdio: 'inherit', env: process.env
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
});
|
});
|
||||||
if (backup.error) throw backup.error;
|
if (backup.error) throw backup.error;
|
||||||
if (backup.status !== 0) throw new Error('Safety backup failed; no managed images were imported.');
|
if (backup.status !== 0)
|
||||||
const assetBackup = spawnSync(process.execPath, ['scripts/isac-data-export.mjs'], {
|
throw new Error('Safety backup failed; no managed images were imported.');
|
||||||
cwd: process.cwd(), stdio: 'inherit', env: process.env
|
const assetBackup = spawnSync(
|
||||||
});
|
process.execPath,
|
||||||
|
['scripts/isac-data-export.mjs'],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (assetBackup.error) throw assetBackup.error;
|
if (assetBackup.error) throw assetBackup.error;
|
||||||
if (assetBackup.status !== 0) throw new Error('Uploaded-asset safety snapshot failed; no managed images were imported.');
|
if (assetBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
'Uploaded-asset safety snapshot failed; no managed images were imported.',
|
||||||
|
);
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
const storageDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
|
const storageDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
await mkdir(storageDirectory, { recursive: true });
|
await mkdir(storageDirectory, { recursive: true });
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
@@ -53,64 +103,140 @@ let unchanged = 0;
|
|||||||
|
|
||||||
const preflight = [];
|
const preflight = [];
|
||||||
for (const image of bundle.images) {
|
for (const image of bundle.images) {
|
||||||
const row = image.row;
|
const row = image.row;
|
||||||
const existing = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(row.id);
|
const existing = await (
|
||||||
if (existing && !updateExisting) {
|
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||||
const same = ['name','category','tags_json','is_icon','force_white','storage_key','byte_size','width','height','source_entity_type','source_entity_id']
|
).get(row.id);
|
||||||
.every((key) => String(existing[key]) === String(row[key]));
|
if (existing && !updateExisting) {
|
||||||
if (!same) throw new Error(`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`);
|
const same = [
|
||||||
unchanged++;
|
'name',
|
||||||
continue;
|
'category',
|
||||||
}
|
'tags_json',
|
||||||
const creator = await (await database.prepare('SELECT id FROM users WHERE id=?')).get(row.created_by);
|
'is_icon',
|
||||||
if (!creator) throw new Error(`Image "${row.name}" references a creator that does not exist in the target database.`);
|
'force_white',
|
||||||
const nameConflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?')).get(row.name, row.id);
|
'storage_key',
|
||||||
if (nameConflict) throw new Error(`Image name "${row.name}" is already assigned to a different target asset.`);
|
'byte_size',
|
||||||
if (row.source_entity_type && row.source_entity_id) {
|
'width',
|
||||||
const sourceConflict = await (await database.prepare(
|
'height',
|
||||||
'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?'
|
'source_entity_type',
|
||||||
)).get(row.source_entity_type, row.source_entity_id, row.id);
|
'source_entity_id',
|
||||||
if (sourceConflict) throw new Error(`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`);
|
].every((key) => String(existing[key]) === String(row[key]));
|
||||||
}
|
if (!same)
|
||||||
preflight.push({ image, existing });
|
throw new Error(
|
||||||
|
`Image ${row.id} already exists with different metadata. Re-run with --update-existing after review.`,
|
||||||
|
);
|
||||||
|
unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const creator = await (
|
||||||
|
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||||
|
).get(row.created_by);
|
||||||
|
if (!creator)
|
||||||
|
throw new Error(
|
||||||
|
`Image "${row.name}" references a creator that does not exist in the target database.`,
|
||||||
|
);
|
||||||
|
const nameConflict = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id FROM managed_images WHERE name=? COLLATE NOCASE AND id<>?',
|
||||||
|
)
|
||||||
|
).get(row.name, row.id);
|
||||||
|
if (nameConflict)
|
||||||
|
throw new Error(
|
||||||
|
`Image name "${row.name}" is already assigned to a different target asset.`,
|
||||||
|
);
|
||||||
|
if (row.source_entity_type && row.source_entity_id) {
|
||||||
|
const sourceConflict = await (
|
||||||
|
await database.prepare(
|
||||||
|
'SELECT id FROM managed_images WHERE source_entity_type=? AND source_entity_id=? AND id<>?',
|
||||||
|
)
|
||||||
|
).get(row.source_entity_type, row.source_entity_id, row.id);
|
||||||
|
if (sourceConflict)
|
||||||
|
throw new Error(
|
||||||
|
`The ${row.source_entity_type} image ${row.source_entity_id} is already registered under a different library asset.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
preflight.push({ image, existing });
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const { image, existing } of preflight) {
|
for (const { image, existing } of preflight) {
|
||||||
const row = image.row;
|
const row = image.row;
|
||||||
await writeFile(join(storageDirectory, row.storage_key), Buffer.from(image.contentBase64, 'base64'), {
|
await writeFile(
|
||||||
mode: 0o600, flag: existing || row.source_entity_type ? 'w' : 'wx'
|
join(storageDirectory, row.storage_key),
|
||||||
});
|
Buffer.from(image.contentBase64, 'base64'),
|
||||||
if (existing) {
|
{
|
||||||
await (await database.prepare(`UPDATE managed_images SET
|
mode: 0o600,
|
||||||
|
flag: existing || row.source_entity_type ? 'w' : 'wx',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await (
|
||||||
|
await database.prepare(`UPDATE managed_images SET
|
||||||
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
|
name=?,category=?,tags_json=?,is_icon=?,force_white=?,storage_key=?,original_filename=?,
|
||||||
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)).run(
|
byte_size=?,width=?,height=?,source_entity_type=?,source_entity_id=?,updated_at=? WHERE id=?`)
|
||||||
row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
|
).run(
|
||||||
row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,row.updated_at,row.id
|
row.name,
|
||||||
);
|
row.category,
|
||||||
updated++;
|
row.tags_json,
|
||||||
} else {
|
row.is_icon,
|
||||||
await (await database.prepare(`INSERT INTO managed_images
|
row.force_white,
|
||||||
|
row.storage_key,
|
||||||
|
row.original_filename,
|
||||||
|
row.byte_size,
|
||||||
|
row.width,
|
||||||
|
row.height,
|
||||||
|
row.source_entity_type ?? null,
|
||||||
|
row.source_entity_id ?? null,
|
||||||
|
row.updated_at,
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO managed_images
|
||||||
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
||||||
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
row.id,row.name,row.category,row.tags_json,row.is_icon,row.force_white,row.storage_key,row.original_filename,
|
).run(
|
||||||
row.byte_size,row.width,row.height,row.source_entity_type ?? null,row.source_entity_id ?? null,
|
row.id,
|
||||||
row.created_by,row.created_at,row.updated_at
|
row.name,
|
||||||
);
|
row.category,
|
||||||
inserted++;
|
row.tags_json,
|
||||||
}
|
row.is_icon,
|
||||||
await (await database.prepare(`INSERT INTO audit_log
|
row.force_white,
|
||||||
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)).run(
|
row.storage_key,
|
||||||
randomUUID(), row.created_by, 'system_configuration', row.id,
|
row.original_filename,
|
||||||
existing ? 'image.import_updated' : 'image.imported',
|
row.byte_size,
|
||||||
existing ? JSON.stringify(existing) : null, JSON.stringify(row), new Date().toISOString()
|
row.width,
|
||||||
);
|
row.height,
|
||||||
}
|
row.source_entity_type ?? null,
|
||||||
await database.exec('COMMIT');
|
row.source_entity_id ?? null,
|
||||||
|
row.created_by,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at,
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO audit_log
|
||||||
|
(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)`)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
row.created_by,
|
||||||
|
'system_configuration',
|
||||||
|
row.id,
|
||||||
|
existing ? 'image.import_updated' : 'image.imported',
|
||||||
|
existing ? JSON.stringify(existing) : null,
|
||||||
|
JSON.stringify(row),
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.log(`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`);
|
console.log(
|
||||||
|
`Managed-image merge complete: ${inserted} inserted, ${updated} updated, ${unchanged} unchanged.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,40 +2,78 @@ import { connect } from '@tursodatabase/database';
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) throw new Error('SMTP delivery is not configured.');
|
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM)
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
throw new Error('SMTP delivery is not configured.');
|
||||||
|
const database = await connect(
|
||||||
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
const transport = nodemailer.createTransport({
|
const transport = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
auth: process.env.SMTP_USER
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableFileAccess: true,
|
: undefined,
|
||||||
disableUrlAccess: true
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
});
|
});
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const recipients = await (
|
||||||
|
await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
||||||
JOIN user_emails e ON e.user_id=n.user_id
|
JOIN user_emails e ON e.user_id=n.user_id
|
||||||
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all();
|
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)
|
||||||
|
).all();
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
|
const items = await (
|
||||||
|
await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
|
||||||
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
||||||
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)).all(recipient.user_id);
|
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`)
|
||||||
if (!items.length) continue;
|
).all(recipient.user_id);
|
||||||
const escape = (value) => String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
if (!items.length) continue;
|
||||||
const rows = items.map((item) => `<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`).join('');
|
const escape = (value) =>
|
||||||
await transport.sendMail({
|
String(value).replace(
|
||||||
from: process.env.SMTP_FROM,
|
/[&<>"']/g,
|
||||||
to: recipient.email,
|
(character) =>
|
||||||
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
|
({
|
||||||
text: items.map((item) => `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`).join('\n\n'),
|
'&': '&',
|
||||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`
|
'<': '<',
|
||||||
});
|
'>': '>',
|
||||||
const now = new Date().toISOString();
|
'"': '"',
|
||||||
for (const item of items) await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL')).run(now, item.id);
|
"'": ''',
|
||||||
sent += 1;
|
})[character],
|
||||||
|
);
|
||||||
|
const rows = items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escape(item.title)}</strong><p style="color:#a5aaab">${escape(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${origin}${item.link}">VIEW INTELLIGENCE</a>` : ''}</div>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
await transport.sendMail({
|
||||||
|
from: process.env.SMTP_FROM,
|
||||||
|
to: recipient.email,
|
||||||
|
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
|
||||||
|
text: items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`,
|
||||||
|
)
|
||||||
|
.join('\n\n'),
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`,
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const item of items)
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL',
|
||||||
|
)
|
||||||
|
).run(now, item.id);
|
||||||
|
sent += 1;
|
||||||
}
|
}
|
||||||
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
|
console.log(`Sent ${sent} daily notification digest${sent === 1 ? '' : 's'}.`);
|
||||||
|
|||||||
@@ -3,67 +3,100 @@ import nodemailer from 'nodemailer';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue
|
const due = await (
|
||||||
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now);
|
await database.prepare(`SELECT * FROM oversight_notification_queue
|
||||||
|
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)
|
||||||
|
).all(now);
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const item of due) {
|
for (const item of due) {
|
||||||
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key).push(item);
|
groups.get(key).push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
const transport = smtpConfigured
|
||||||
host: process.env.SMTP_HOST,
|
? nodemailer.createTransport({
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
host: process.env.SMTP_HOST,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
auth: process.env.SMTP_USER
|
||||||
disableFileAccess: true,
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableUrlAccess: true
|
: undefined,
|
||||||
}) : null;
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
|
|
||||||
for (const items of groups.values()) {
|
for (const items of groups.values()) {
|
||||||
const first = items[0];
|
const first = items[0];
|
||||||
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
const categoryLabel =
|
||||||
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
||||||
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n');
|
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
||||||
const remainder = items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
const details = items
|
||||||
const body = `${details}${remainder}`;
|
.slice(0, 10)
|
||||||
const notificationId = randomUUID();
|
.map((item) => `• ${item.summary}`)
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
.join('\n');
|
||||||
|
const remainder =
|
||||||
|
items.length > 10 ? `\n• ${items.length - 10} additional review items` : '';
|
||||||
|
const body = `${details}${remainder}`;
|
||||||
|
const notificationId = randomUUID();
|
||||||
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||||
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
|
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)
|
||||||
notificationId, first.user_id, title, body, first.link, now
|
).run(notificationId, first.user_id, title, body, first.link, now);
|
||||||
);
|
for (const item of items) {
|
||||||
for (const item of items) {
|
await (
|
||||||
await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id);
|
await database.prepare(
|
||||||
}
|
'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL',
|
||||||
const preference = await (await database.prepare(
|
)
|
||||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
).run(now, item.id);
|
||||||
)).get(first.user_id);
|
}
|
||||||
if (transport && preference?.email_frequency === 'immediate') {
|
const preference = await (
|
||||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
|
await database.prepare(
|
||||||
if (typeof identity?.email === 'string') {
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
|
||||||
try {
|
)
|
||||||
await transport.sendMail({
|
).get(first.user_id);
|
||||||
from: process.env.SMTP_FROM,
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
to: identity.email,
|
const identity = await (
|
||||||
subject: `SHD.build · ${title}`,
|
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||||
text: `${body}\n\n${origin}${first.link}`,
|
).get(first.user_id);
|
||||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`
|
if (typeof identity?.email === 'string') {
|
||||||
});
|
try {
|
||||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
await transport.sendMail({
|
||||||
} catch (error) {
|
from: process.env.SMTP_FROM,
|
||||||
console.error(`Unable to email Oversight digest ${notificationId}`, error);
|
to: identity.email,
|
||||||
}
|
subject: `SHD.build · ${title}`,
|
||||||
}
|
text: `${body}\n\n${origin}${first.link}`,
|
||||||
}
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`,
|
||||||
delivered += items.length;
|
});
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Unable to email Oversight digest ${notificationId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += items.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`);
|
console.log(
|
||||||
|
`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const omitted = new Set(
|
||||||
|
String(process.env.npm_config_omit ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!omitted.has('dev') && process.env.NODE_ENV !== 'production') {
|
||||||
|
const { default: husky } = await import('husky');
|
||||||
|
husky();
|
||||||
|
}
|
||||||
@@ -6,35 +6,47 @@ import sharp from 'sharp';
|
|||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const outputDirectory = path.join(root, 'static', 'brand');
|
const outputDirectory = path.join(root, 'static', 'brand');
|
||||||
const fontDirectory = path.join(root, 'node_modules', '@fontsource', 'barlow', 'files');
|
const fontDirectory = path.join(
|
||||||
const heavy = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff')).buffer);
|
root,
|
||||||
const medium = opentype.parse(fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff')).buffer);
|
'node_modules',
|
||||||
|
'@fontsource',
|
||||||
|
'barlow',
|
||||||
|
'files',
|
||||||
|
);
|
||||||
|
const heavy = opentype.parse(
|
||||||
|
fs.readFileSync(path.join(fontDirectory, 'barlow-latin-900-normal.woff'))
|
||||||
|
.buffer,
|
||||||
|
);
|
||||||
|
const medium = opentype.parse(
|
||||||
|
fs.readFileSync(path.join(fontDirectory, 'barlow-latin-700-normal.woff'))
|
||||||
|
.buffer,
|
||||||
|
);
|
||||||
|
|
||||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
background: '#0a0c0d',
|
background: '#0a0c0d',
|
||||||
surface: '#111415',
|
surface: '#111415',
|
||||||
border: '#303536',
|
border: '#303536',
|
||||||
grid: '#2a3031',
|
grid: '#2a3031',
|
||||||
orange: '#f6a821',
|
orange: '#f6a821',
|
||||||
white: '#ecebe5'
|
white: '#ecebe5',
|
||||||
};
|
};
|
||||||
|
|
||||||
function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
function trackedPath(font, value, x, baseline, size, tracking = 0) {
|
||||||
let cursor = x;
|
let cursor = x;
|
||||||
let data = '';
|
let data = '';
|
||||||
for (const character of value) {
|
for (const character of value) {
|
||||||
const glyph = font.charToGlyph(character);
|
const glyph = font.charToGlyph(character);
|
||||||
data += glyph.getPath(cursor, baseline, size).toPathData(2);
|
data += glyph.getPath(cursor, baseline, size).toPathData(2);
|
||||||
cursor += glyph.advanceWidth * size / font.unitsPerEm + tracking;
|
cursor += (glyph.advanceWidth * size) / font.unitsPerEm + tracking;
|
||||||
}
|
}
|
||||||
return { data, width: cursor - x - tracking };
|
return { data, width: cursor - x - tracking };
|
||||||
}
|
}
|
||||||
|
|
||||||
function emblem(x, y, size) {
|
function emblem(x, y, size) {
|
||||||
const scale = size / 100;
|
const scale = size / 100;
|
||||||
return `
|
return `
|
||||||
<g transform="translate(${x} ${y}) scale(${scale})">
|
<g transform="translate(${x} ${y}) scale(${scale})">
|
||||||
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
|
<g fill="none" stroke="${colors.orange}" stroke-width="12" stroke-linecap="butt">
|
||||||
<path d="M73 20A38 38 0 0 0 15 63"/>
|
<path d="M73 20A38 38 0 0 0 15 63"/>
|
||||||
@@ -47,10 +59,10 @@ function emblem(x, y, size) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writePng(svg, filename, width, height) {
|
async function writePng(svg, filename, width, height) {
|
||||||
await sharp(Buffer.from(svg))
|
await sharp(Buffer.from(svg))
|
||||||
.resize(width, height, { fit: 'fill' })
|
.resize(width, height, { fit: 'fill' })
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
.toFile(path.join(outputDirectory, filename));
|
.toFile(path.join(outputDirectory, filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
const transparentLogo = `
|
const transparentLogo = `
|
||||||
@@ -154,10 +166,17 @@ const inviteSplash = `
|
|||||||
await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024);
|
await writePng(transparentLogo, 'shd-core-segments.png', 1024, 1024);
|
||||||
await writePng(serverIcon, 'shd-build-discord-icon.png', 1024, 1024);
|
await writePng(serverIcon, 'shd-build-discord-icon.png', 1024, 1024);
|
||||||
await writePng(banner, 'shd-build-discord-banner-1920x1080.png', 1920, 1080);
|
await writePng(banner, 'shd-build-discord-banner-1920x1080.png', 1920, 1080);
|
||||||
await writePng(inviteSplash, 'shd-build-discord-invite-background-1920x1080.png', 1920, 1080);
|
await writePng(
|
||||||
await sharp(path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'))
|
inviteSplash,
|
||||||
.resize(960, 540)
|
'shd-build-discord-invite-background-1920x1080.png',
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
1920,
|
||||||
.toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png'));
|
1080,
|
||||||
|
);
|
||||||
|
await sharp(
|
||||||
|
path.join(outputDirectory, 'shd-build-discord-banner-1920x1080.png'),
|
||||||
|
)
|
||||||
|
.resize(960, 540)
|
||||||
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
|
.toFile(path.join(outputDirectory, 'shd-build-discord-banner-960x540.png'));
|
||||||
|
|
||||||
console.log(`Rendered brand assets to ${outputDirectory}`);
|
console.log(`Rendered brand assets to ${outputDirectory}`);
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/** @typedef {'app' | 'scheduler'} RuntimeMode */
|
||||||
|
/** @typedef {Record<string, string | undefined>} RuntimeEnvironment */
|
||||||
|
|
||||||
|
const APP_REQUIRED = [
|
||||||
|
'APP_ORIGIN',
|
||||||
|
'ORIGIN',
|
||||||
|
'PASSKEY_RP_ID',
|
||||||
|
'SCHEDULER_TOKEN',
|
||||||
|
'TDR_DATABASE_PATH',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate environment variables after the runtime secret provider has injected them.
|
||||||
|
*
|
||||||
|
* @param {RuntimeMode} mode
|
||||||
|
* @param {RuntimeEnvironment} environment
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function validateRuntimeEnvironment(mode, environment) {
|
||||||
|
if (mode !== 'app' && mode !== 'scheduler') {
|
||||||
|
throw new TypeError(`Unsupported runtime mode: ${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const get = (name) => environment[name]?.trim() ?? '';
|
||||||
|
const requireValues = (names) => {
|
||||||
|
for (const name of names) {
|
||||||
|
if (!get(name)) errors.push(`${name} is required.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'scheduler') {
|
||||||
|
requireValues(['SCHEDULER_URL', 'SCHEDULER_TOKEN']);
|
||||||
|
validateHttpUrl('SCHEDULER_URL', get('SCHEDULER_URL'), errors);
|
||||||
|
if (
|
||||||
|
get('SCHEDULER_URL') &&
|
||||||
|
get('SCHEDULER_URL') !== 'http://app:3000/api/internal/scheduler'
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
requireValues(APP_REQUIRED);
|
||||||
|
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||||
|
|
||||||
|
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
||||||
|
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
|
||||||
|
if (
|
||||||
|
appOrigin &&
|
||||||
|
adapterOrigin &&
|
||||||
|
appOrigin.hostname !== adapterOrigin.hostname
|
||||||
|
) {
|
||||||
|
errors.push('APP_ORIGIN and ORIGIN must use the same hostname.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpId = get('PASSKEY_RP_ID').toLowerCase();
|
||||||
|
if (rpId && !isHostname(rpId)) {
|
||||||
|
errors.push('PASSKEY_RP_ID must be a hostname.');
|
||||||
|
} else if (
|
||||||
|
appOrigin &&
|
||||||
|
rpId &&
|
||||||
|
appOrigin.hostname !== rpId &&
|
||||||
|
!appOrigin.hostname.endsWith(`.${rpId}`)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePair(
|
||||||
|
'DISCORD_CLIENT_ID',
|
||||||
|
get('DISCORD_CLIENT_ID'),
|
||||||
|
'DISCORD_CLIENT_SECRET',
|
||||||
|
get('DISCORD_CLIENT_SECRET'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
requirePair(
|
||||||
|
'TURNSTILE_SITE_KEY',
|
||||||
|
get('TURNSTILE_SITE_KEY'),
|
||||||
|
'TURNSTILE_SECRET_KEY',
|
||||||
|
get('TURNSTILE_SECRET_KEY'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
|
||||||
|
const smtpEnabled = [
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_FROM',
|
||||||
|
'SMTP_USER',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
].some((name) => Boolean(get(name)));
|
||||||
|
if (smtpEnabled) {
|
||||||
|
requirePair(
|
||||||
|
'SMTP_HOST',
|
||||||
|
get('SMTP_HOST'),
|
||||||
|
'SMTP_FROM',
|
||||||
|
get('SMTP_FROM'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
requirePair(
|
||||||
|
'SMTP_USER',
|
||||||
|
get('SMTP_USER'),
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
get('SMTP_PASSWORD'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpPort = get('SMTP_PORT');
|
||||||
|
if (
|
||||||
|
smtpPort &&
|
||||||
|
(!/^\d+$/.test(smtpPort) ||
|
||||||
|
Number(smtpPort) < 1 ||
|
||||||
|
Number(smtpPort) > 65535)
|
||||||
|
) {
|
||||||
|
errors.push('SMTP_PORT must be an integer from 1 through 65535.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtpSecure = get('SMTP_SECURE');
|
||||||
|
if (smtpSecure && !['true', 'false'].includes(smtpSecure)) {
|
||||||
|
errors.push('SMTP_SECURE must be true or false.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordRedirect = validateHttpUrl(
|
||||||
|
'DISCORD_REDIRECT_URI',
|
||||||
|
get('DISCORD_REDIRECT_URI'),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
appOrigin &&
|
||||||
|
discordRedirect &&
|
||||||
|
(discordRedirect.hostname !== appOrigin.hostname ||
|
||||||
|
discordRedirect.pathname !== '/auth/discord/callback')
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
'TDR_DATABASE_PATH',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH',
|
||||||
|
]) {
|
||||||
|
if (get(name) && !get(name).startsWith('/')) {
|
||||||
|
errors.push(`${name} must be an absolute hosted path.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} firstName
|
||||||
|
* @param {string} firstValue
|
||||||
|
* @param {string} secondName
|
||||||
|
* @param {string} secondValue
|
||||||
|
* @param {string[]} errors
|
||||||
|
*/
|
||||||
|
function requirePair(firstName, firstValue, secondName, secondValue, errors) {
|
||||||
|
if (Boolean(firstValue) !== Boolean(secondValue)) {
|
||||||
|
errors.push(`${firstName} and ${secondName} must be configured together.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} value
|
||||||
|
* @param {string[]} errors
|
||||||
|
* @returns {URL | undefined}
|
||||||
|
*/
|
||||||
|
function validateHttpUrl(name, value, errors) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {
|
||||||
|
throw new TypeError('not an HTTP URL');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
errors.push(`${name} must be a valid HTTP or HTTPS URL.`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} value
|
||||||
|
* @param {string[]} errors
|
||||||
|
* @returns {URL | undefined}
|
||||||
|
*/
|
||||||
|
function validateOrigin(name, value, errors) {
|
||||||
|
const url = validateHttpUrl(name, value, errors);
|
||||||
|
if (!url) return undefined;
|
||||||
|
if (
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.pathname !== '/' ||
|
||||||
|
url.search ||
|
||||||
|
url.hash
|
||||||
|
) {
|
||||||
|
errors.push(`${name} must contain only a URL origin.`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} value */
|
||||||
|
function isHostname(value) {
|
||||||
|
if (value === 'localhost') return true;
|
||||||
|
if (value.length > 253 || !value.includes('.')) return false;
|
||||||
|
return value
|
||||||
|
.split('.')
|
||||||
|
.every(
|
||||||
|
(label) =>
|
||||||
|
label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} value @param {string[]} errors */
|
||||||
|
function validateSchedulerToken(value, errors) {
|
||||||
|
if (value && (value.length < 32 || /\s/.test(value))) {
|
||||||
|
errors.push(
|
||||||
|
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||||
|
|
||||||
|
const validAppEnvironment = {
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
ORIGIN: 'https://stage.shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateRuntimeEnvironment', () => {
|
||||||
|
it('accepts the hosted app contract without optional providers', () => {
|
||||||
|
expect(validateRuntimeEnvironment('app', validAppEnvironment)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports every missing required app value', () => {
|
||||||
|
expect(validateRuntimeEnvironment('app', {})).toEqual([
|
||||||
|
'APP_ORIGIN is required.',
|
||||||
|
'ORIGIN is required.',
|
||||||
|
'PASSKEY_RP_ID is required.',
|
||||||
|
'SCHEDULER_TOKEN is required.',
|
||||||
|
'TDR_DATABASE_PATH is required.',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH is required.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates canonical origins and passkey hostname scope', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build/sign-in',
|
||||||
|
}),
|
||||||
|
).toContain('APP_ORIGIN must contain only a URL origin.');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
PASSKEY_RP_ID: 'example.com',
|
||||||
|
}),
|
||||||
|
).toContain(
|
||||||
|
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mismatched app and adapter hostnames', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
ORIGIN: 'https://other.shd.build',
|
||||||
|
}),
|
||||||
|
).toContain('APP_ORIGIN and ORIGIN must use the same hostname.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires optional integration credentials only as pairs', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
DISCORD_CLIENT_ID: 'client-id',
|
||||||
|
TURNSTILE_SECRET_KEY: 'secret',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET must be configured together.',
|
||||||
|
'TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must be configured together.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows SMTP defaults without enabling email and validates enabled SMTP', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
SMTP_PORT: '587',
|
||||||
|
SMTP_SECURE: 'false',
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
SMTP_HOST: 'smtp.example.com',
|
||||||
|
SMTP_USER: 'agent',
|
||||||
|
SMTP_PORT: '70000',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SMTP_HOST and SMTP_FROM must be configured together.',
|
||||||
|
'SMTP_USER and SMTP_PASSWORD must be configured together.',
|
||||||
|
'SMTP_PORT must be an integer from 1 through 65535.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates optional redirect URLs when supplied', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
DISCORD_REDIRECT_URI: 'ftp://stage.shd.build/callback',
|
||||||
|
}),
|
||||||
|
).toContain('DISCORD_REDIRECT_URI must be a valid HTTP or HTTPS URL.');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
DISCORD_REDIRECT_URI: 'https://dev.shd.build/auth/discord/callback',
|
||||||
|
}),
|
||||||
|
).toContain(
|
||||||
|
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates hosted paths and SMTP TLS configuration', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('app', {
|
||||||
|
...validAppEnvironment,
|
||||||
|
SMTP_SECURE: 'yes',
|
||||||
|
TDR_DATABASE_PATH: './data/staging.db',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SMTP_SECURE must be true or false.',
|
||||||
|
'TDR_DATABASE_PATH must be an absolute hosted path.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a configured scheduler and rejects missing or invalid values', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('scheduler', {
|
||||||
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||||
|
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('scheduler', {
|
||||||
|
SCHEDULER_URL: 'not a URL',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SCHEDULER_TOKEN is required.',
|
||||||
|
'SCHEDULER_URL must be a valid HTTP or HTTPS URL.',
|
||||||
|
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a strong scheduler token and fixed internal endpoint', () => {
|
||||||
|
expect(
|
||||||
|
validateRuntimeEnvironment('scheduler', {
|
||||||
|
SCHEDULER_URL: 'https://example.com/api/internal/scheduler',
|
||||||
|
SCHEDULER_TOKEN: 'too-short',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
'SCHEDULER_URL must use the internal app scheduler endpoint.',
|
||||||
|
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported runtime modes', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||||
|
).toThrow('Unsupported runtime mode: worker');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readFile, unlink } from 'node:fs/promises';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||||
|
|
||||||
|
const APP_SECRET_NAMES = new Set([
|
||||||
|
'APP_ORIGIN',
|
||||||
|
'DISCORD_CLIENT_ID',
|
||||||
|
'DISCORD_CLIENT_SECRET',
|
||||||
|
'DISCORD_REDIRECT_URI',
|
||||||
|
'OPENAI_MECHANIC_API_KEY',
|
||||||
|
'OPENAI_MECHANIC_MODEL',
|
||||||
|
'OPENAI_MECHANIC_REASONING_EFFORT',
|
||||||
|
'OPENAI_MODERATION_API_KEY',
|
||||||
|
'ORIGIN',
|
||||||
|
'PASSKEY_RP_ID',
|
||||||
|
'REMOVE_BG_API_KEY',
|
||||||
|
'REMOVE_BG_CREDIT_CHECK_HOURS',
|
||||||
|
'REMOVE_BG_LOW_CREDIT_THRESHOLD',
|
||||||
|
'REMOVE_BG_LOW_FREE_CALL_THRESHOLD',
|
||||||
|
'SMTP_FROM',
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
'SMTP_PORT',
|
||||||
|
'SMTP_SECURE',
|
||||||
|
'SMTP_USER',
|
||||||
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
|
'TDR_DATABASE_PATH',
|
||||||
|
'TDR_GAME_ICON_STORAGE_PATH',
|
||||||
|
'TURNSTILE_SECRET_KEY',
|
||||||
|
'TURNSTILE_SITE_KEY',
|
||||||
|
]);
|
||||||
|
const SHARED_SECRET_NAMES = new Set(['SCHEDULER_TOKEN']);
|
||||||
|
const REMOVED_ENVIRONMENT_NAMES = [
|
||||||
|
'BASH_ENV',
|
||||||
|
'CDPATH',
|
||||||
|
'ENV',
|
||||||
|
'IFS',
|
||||||
|
'INFISICAL_ENVIRONMENT',
|
||||||
|
'INFISICAL_PROJECT_ID',
|
||||||
|
'INFISICAL_RUNTIME_SCOPE',
|
||||||
|
'INFISICAL_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
||||||
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
||||||
|
'LD_LIBRARY_PATH',
|
||||||
|
'LD_PRELOAD',
|
||||||
|
'NODE_OPTIONS',
|
||||||
|
'NODE_PATH',
|
||||||
|
'PYTHONPATH',
|
||||||
|
'TOKEN',
|
||||||
|
];
|
||||||
|
|
||||||
|
const [runtimeScope, environment, buildNumber = '0', commitSha = '', ...rest] =
|
||||||
|
process.argv.slice(2);
|
||||||
|
const separatorIndex = rest.indexOf('--');
|
||||||
|
if (
|
||||||
|
!['app', 'scheduler'].includes(runtimeScope) ||
|
||||||
|
!['staging', 'prod'].includes(environment) ||
|
||||||
|
separatorIndex < 1
|
||||||
|
) {
|
||||||
|
console.error(
|
||||||
|
'Usage: runtime-launcher.mjs <app|scheduler> <staging|prod> <build> <sha> <secret-files...> -- <command...>',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretFiles = rest.slice(0, separatorIndex);
|
||||||
|
const requestedCommand = rest.slice(separatorIndex + 1);
|
||||||
|
if (requestedCommand.length === 0) {
|
||||||
|
console.error('A runtime command is required.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFileCount = runtimeScope === 'app' ? 2 : 1;
|
||||||
|
if (secretFiles.length !== expectedFileCount) {
|
||||||
|
console.error(
|
||||||
|
`${runtimeScope} requires ${expectedFileCount} secret export file(s).`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = {};
|
||||||
|
try {
|
||||||
|
await loadSecretFile(
|
||||||
|
secretFiles[0],
|
||||||
|
runtimeScope === 'app' ? APP_SECRET_NAMES : SHARED_SECRET_NAMES,
|
||||||
|
secrets,
|
||||||
|
);
|
||||||
|
if (runtimeScope === 'app') {
|
||||||
|
await loadSecretFile(secretFiles[1], SHARED_SECRET_NAMES, secrets);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const deletionResults = await Promise.allSettled(
|
||||||
|
secretFiles.map((file) => unlink(file)),
|
||||||
|
);
|
||||||
|
const undeletedFiles = secretFiles.filter(
|
||||||
|
(_, index) => deletionResults[index].status === 'rejected',
|
||||||
|
);
|
||||||
|
if (undeletedFiles.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to delete Infisical secret export: ${undeletedFiles.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childEnvironment = { ...process.env, ...secrets };
|
||||||
|
for (const name of REMOVED_ENVIRONMENT_NAMES) delete childEnvironment[name];
|
||||||
|
Object.assign(
|
||||||
|
childEnvironment,
|
||||||
|
trustedRuntimeEnvironment(runtimeScope, environment, buildNumber, commitSha),
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors = validateRuntimeEnvironment(runtimeScope, childEnvironment);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Invalid ${runtimeScope} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.umask(0o007);
|
||||||
|
|
||||||
|
if (requestedCommand[0] === 'serve') {
|
||||||
|
await runAsNode(['node', 'scripts/db-migrate.mjs'], childEnvironment);
|
||||||
|
await runAsNode(['node', 'build'], childEnvironment);
|
||||||
|
} else if (requestedCommand[0] === 'scheduler') {
|
||||||
|
await runAsNode(['node', 'scripts/scheduler-client.mjs'], childEnvironment);
|
||||||
|
} else {
|
||||||
|
await runAsNode(requestedCommand, childEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecretFile(file, allowedNames, target) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = JSON.parse(await readFile(file, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new Error('Infisical returned an unreadable secret export.');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
throw new Error('Infisical secret export must be an array.');
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = entry?.key;
|
||||||
|
const value = entry?.value;
|
||||||
|
if (typeof name !== 'string' || typeof value !== 'string') {
|
||||||
|
throw new Error('Infisical secret export contains an invalid entry.');
|
||||||
|
}
|
||||||
|
if (!allowedNames.has(name)) {
|
||||||
|
throw new Error(
|
||||||
|
`Infisical secret ${name} is not allowed in this runtime scope.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(target, name)) {
|
||||||
|
throw new Error(`Infisical secret ${name} was exported more than once.`);
|
||||||
|
}
|
||||||
|
target[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedRuntimeEnvironment(
|
||||||
|
scope,
|
||||||
|
targetEnvironment,
|
||||||
|
release,
|
||||||
|
revision,
|
||||||
|
) {
|
||||||
|
const common = {
|
||||||
|
BODY_SIZE_LIMIT: '6M',
|
||||||
|
HOST: '0.0.0.0',
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: '3000',
|
||||||
|
SHD_BUILD_NUMBER: release,
|
||||||
|
SHD_COMMIT_SHA: revision,
|
||||||
|
};
|
||||||
|
if (scope === 'scheduler') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (targetEnvironment === 'staging') {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
DISCORD_REDIRECT_URI: 'https://stage.shd.build/auth/discord/callback',
|
||||||
|
ORIGIN: 'https://stage.shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SKIP_DEFAULT_USERS: 'true',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
UBISOFT_NEWS_IMPORT_ENABLED: 'false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
APP_ORIGIN: 'https://shd.build',
|
||||||
|
DISCORD_REDIRECT_URI: 'https://shd.build/auth/discord/callback',
|
||||||
|
ORIGIN: 'https://shd.build',
|
||||||
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
SKIP_DEFAULT_USERS: 'true',
|
||||||
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
|
TDR_DATABASE_PATH: '/app/data/shd-build-production.db',
|
||||||
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
||||||
|
UBISOFT_NEWS_IMPORT_ENABLED: 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAsNode(command, environmentVariables) {
|
||||||
|
const usesPrivilegeDrop =
|
||||||
|
typeof process.getuid === 'function' && process.getuid() === 0;
|
||||||
|
const executable = usesPrivilegeDrop ? 'gosu' : command[0];
|
||||||
|
const args = usesPrivilegeDrop
|
||||||
|
? ['shd-runtime', ...command]
|
||||||
|
: command.slice(1);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(executable, args, {
|
||||||
|
env: environmentVariables,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
const signalHandlers = new Map();
|
||||||
|
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
||||||
|
const handler = () => child.kill(signal);
|
||||||
|
signalHandlers.set(signal, handler);
|
||||||
|
process.once(signal, handler);
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const [signal, handler] of signalHandlers) {
|
||||||
|
process.off(signal, handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.once('error', (error) => {
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
cleanup();
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
} else if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Runtime command exited with status ${code ?? 1}.`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
const endpoint = process.env.SCHEDULER_URL;
|
const endpoint = process.env.SCHEDULER_URL;
|
||||||
const token = process.env.SCHEDULER_TOKEN;
|
const token = process.env.SCHEDULER_TOKEN;
|
||||||
if (!endpoint || !token) throw new Error('SCHEDULER_URL and SCHEDULER_TOKEN are required.');
|
if (!endpoint || !token)
|
||||||
|
throw new Error('SCHEDULER_URL and SCHEDULER_TOKEN are required.');
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { authorization: `Bearer ${token}` }
|
headers: { authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
|
if (!response.ok)
|
||||||
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
|
throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
|
||||||
} catch (error) {
|
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
|
||||||
console.error('Scheduler tick failed', error);
|
} catch (error) {
|
||||||
}
|
console.error('Scheduler tick failed', error);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { initializeDefaultUsers } from './default-users.mjs';
|
import { initializeDefaultUsers } from './default-users.mjs';
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||||
|
);
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
const resetPasswords = process.argv.includes('--reset-passwords');
|
const resetPasswords = process.argv.includes('--reset-passwords');
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,63 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const targetName = process.argv[2] || 'Aewyn';
|
const targetName = process.argv[2] || 'Aewyn';
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
const user = await (await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
|
const user = await (
|
||||||
|
await database.prepare(`SELECT users.id,COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
WHERE users.login_username=? COLLATE NOCASE OR users.username=? COLLATE NOCASE OR user_profiles.display_name=? COLLATE NOCASE
|
WHERE users.login_username=? COLLATE NOCASE OR users.username=? COLLATE NOCASE OR user_profiles.display_name=? COLLATE NOCASE
|
||||||
LIMIT 1`)).get(targetName, targetName, targetName);
|
LIMIT 1`)
|
||||||
|
).get(targetName, targetName, targetName);
|
||||||
if (!user) throw new Error(`No user matched ${targetName}.`);
|
if (!user) throw new Error(`No user matched ${targetName}.`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const samples = [
|
const samples = [
|
||||||
['isac', 'ISAC AVATAR OVERSIGHT', '[TEST] Custom avatar removed', 'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.', '/account#avatar', 1],
|
[
|
||||||
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution approved', 'Quick Draw brand-set data was approved and deployed to the live builder registry.', '/isac/brands', 1],
|
'isac',
|
||||||
['contributions', 'ISAC DATA OVERSIGHT', '[TEST] Contribution needs revision', 'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.', '/isac/talents', 1],
|
'ISAC AVATAR OVERSIGHT',
|
||||||
['comments', 'SHD COMMUNITY', '[TEST] New comment on your build', 'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.', '/builds', 0]
|
'[TEST] Custom avatar removed',
|
||||||
|
'Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: The image contained unrelated promotional text.',
|
||||||
|
'/account#avatar',
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'contributions',
|
||||||
|
'ISAC DATA OVERSIGHT',
|
||||||
|
'[TEST] Contribution approved',
|
||||||
|
'Quick Draw brand-set data was approved and deployed to the live builder registry.',
|
||||||
|
'/isac/brands',
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'contributions',
|
||||||
|
'ISAC DATA OVERSIGHT',
|
||||||
|
'[TEST] Contribution needs revision',
|
||||||
|
'A talent update was not approved. Oversight reason: Tier II effect value does not match the supplied game evidence.',
|
||||||
|
'/isac/talents',
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'comments',
|
||||||
|
'SHD COMMUNITY',
|
||||||
|
'[TEST] New comment on your build',
|
||||||
|
'An agent commented on “First Wave Vector Critical Build”: This setup worked really well in Legendary missions.',
|
||||||
|
'/builds',
|
||||||
|
0,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
for (const [index, sample] of samples.entries()) {
|
for (const [index, sample] of samples.entries()) {
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?)`)).run(randomUUID(), user.id, ...sample, new Date(now - index * 60_000).toISOString());
|
VALUES(?,?,?,?,?,?,?,?,?)`)
|
||||||
|
).run(
|
||||||
|
randomUUID(),
|
||||||
|
user.id,
|
||||||
|
...sample,
|
||||||
|
new Date(now - index * 60_000).toISOString(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`Added ${samples.length} test notifications for ${user.display_name}.`);
|
console.log(
|
||||||
|
`Added ${samples.length} test notifications for ${user.display_name}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,51 +4,74 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const outputArgument = process.argv[2];
|
const outputArgument = process.argv[2];
|
||||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
const timestamp = new Date()
|
||||||
const outputPath = resolve(outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`);
|
.toISOString()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const outputPath = resolve(
|
||||||
|
outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`,
|
||||||
|
);
|
||||||
const temporaryBackupPath = `${outputPath}.database.json`;
|
const temporaryBackupPath = `${outputPath}.database.json`;
|
||||||
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
|
const databaseBackup = spawnSync(
|
||||||
cwd: process.cwd(),
|
process.execPath,
|
||||||
stdio: 'inherit',
|
['scripts/db-backup.mjs', temporaryBackupPath],
|
||||||
env: process.env
|
{
|
||||||
});
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (databaseBackup.error) throw databaseBackup.error;
|
if (databaseBackup.error) throw databaseBackup.error;
|
||||||
if (databaseBackup.status !== 0) throw new Error('Database backup failed; no staging bootstrap bundle was created.');
|
if (databaseBackup.status !== 0)
|
||||||
|
throw new Error(
|
||||||
|
'Database backup failed; no staging bootstrap bundle was created.',
|
||||||
|
);
|
||||||
|
|
||||||
async function collectFiles(directory, pattern) {
|
async function collectFiles(directory, pattern) {
|
||||||
try {
|
try {
|
||||||
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
|
const names = (await readdir(directory))
|
||||||
return await Promise.all(names.map(async (name) => ({
|
.filter((name) => pattern.test(name))
|
||||||
name,
|
.sort();
|
||||||
contentBase64: (await readFile(join(directory, name))).toString('base64')
|
return await Promise.all(
|
||||||
})));
|
names.map(async (name) => ({
|
||||||
} catch (error) {
|
name,
|
||||||
if (error?.code === 'ENOENT') return [];
|
contentBase64: (await readFile(join(directory, name))).toString(
|
||||||
throw error;
|
'base64',
|
||||||
}
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
|
||||||
const avatars = await collectFiles(
|
const avatars = await collectFiles(
|
||||||
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
|
||||||
/^[0-9a-f-]{36}\.webp$/i
|
/^[0-9a-f-]{36}\.webp$/i,
|
||||||
);
|
);
|
||||||
const gameIcons = await collectFiles(
|
const gameIcons = await collectFiles(
|
||||||
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
|
||||||
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i
|
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i,
|
||||||
);
|
);
|
||||||
const bundle = {
|
const bundle = {
|
||||||
format: 'shd-build-stage-bootstrap',
|
format: 'shd-build-stage-bootstrap',
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
database,
|
database,
|
||||||
files: { avatars, gameIcons }
|
files: { avatars, gameIcons },
|
||||||
};
|
};
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, {
|
||||||
console.log(`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`);
|
encoding: 'utf8',
|
||||||
console.log(`Bootstrap bundle: ${outputPath}`);
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`,
|
||||||
|
);
|
||||||
|
console.log(`Bootstrap bundle: ${outputPath}`);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(temporaryBackupPath, { force: true });
|
await rm(temporaryBackupPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,106 +4,165 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
|
const bundleArgument = process.argv
|
||||||
if (!bundleArgument || !process.argv.includes('--confirm-empty-staging-replace')
|
.slice(2)
|
||||||
|| process.env.ALLOW_STAGING_BOOTSTRAP !== 'true') {
|
.find((argument) => !argument.startsWith('--'));
|
||||||
console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:');
|
if (
|
||||||
console.error('node scripts/stage-bootstrap-import.mjs <bundle.json> --confirm-empty-staging-replace');
|
!bundleArgument ||
|
||||||
process.exit(2);
|
!process.argv.includes('--confirm-empty-staging-replace') ||
|
||||||
|
process.env.ALLOW_STAGING_BOOTSTRAP !== 'true'
|
||||||
|
) {
|
||||||
|
console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:');
|
||||||
|
console.error(
|
||||||
|
'node scripts/stage-bootstrap-import.mjs <bundle.json> --confirm-empty-staging-replace',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundlePath = resolve(bundleArgument);
|
const bundlePath = resolve(bundleArgument);
|
||||||
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
|
||||||
if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1
|
if (
|
||||||
|| bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1
|
bundle?.format !== 'shd-build-stage-bootstrap' ||
|
||||||
|| !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars)
|
bundle?.version !== 1 ||
|
||||||
|| !Array.isArray(bundle.files?.gameIcons)) {
|
bundle.database?.format !== 'shd-build-database-backup' ||
|
||||||
throw new Error('The selected file is not a supported SHD.build staging bootstrap bundle.');
|
bundle.database?.version !== 1 ||
|
||||||
|
!Array.isArray(bundle.database.tables) ||
|
||||||
|
!Array.isArray(bundle.files?.avatars) ||
|
||||||
|
!Array.isArray(bundle.files?.gameIcons)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The selected file is not a supported SHD.build staging bootstrap bundle.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const file of bundle.files.avatars) {
|
for (const file of bundle.files.avatars) {
|
||||||
if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
|
if (
|
||||||
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
typeof file.name !== 'string' ||
|
||||||
}
|
!/^[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('The bootstrap bundle contains an invalid avatar file.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const file of bundle.files.gameIcons) {
|
for (const file of bundle.files.gameIcons) {
|
||||||
if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
|
if (
|
||||||
throw new Error('The bootstrap bundle contains an invalid game-data icon file.');
|
typeof file.name !== 'string' ||
|
||||||
}
|
!/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) ||
|
||||||
|
typeof file.contentBase64 !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'The bootstrap bundle contains an invalid game-data icon file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db');
|
const databasePath = resolve(
|
||||||
|
process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db',
|
||||||
|
);
|
||||||
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env
|
env: process.env,
|
||||||
});
|
});
|
||||||
if (safetyBackup.error) throw safetyBackup.error;
|
if (safetyBackup.error) throw safetyBackup.error;
|
||||||
if (safetyBackup.status !== 0) throw new Error('Staging safety backup failed; the target was not changed.');
|
if (safetyBackup.status !== 0)
|
||||||
|
throw new Error('Staging safety backup failed; the target was not changed.');
|
||||||
|
|
||||||
const database = await connect(databasePath);
|
const database = await connect(databasePath);
|
||||||
for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) {
|
for (const tableName of [
|
||||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get();
|
'users',
|
||||||
if (Number(row?.count ?? 0) > 0) {
|
'builds',
|
||||||
throw new Error(`Staging bootstrap refused: target table "${tableName}" is not empty.`);
|
'audit_log',
|
||||||
}
|
'data_change_proposals',
|
||||||
|
]) {
|
||||||
|
const row = await (
|
||||||
|
await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)
|
||||||
|
).get();
|
||||||
|
if (Number(row?.count ?? 0) > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Staging bootstrap refused: target table "${tableName}" is not empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetTables = await (await database.prepare(`
|
const targetTables = await (
|
||||||
|
await database.prepare(`
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||||
ORDER BY rowid
|
ORDER BY rowid
|
||||||
`)).all();
|
`)
|
||||||
|
).all();
|
||||||
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
const targetNames = new Set(targetTables.map((row) => String(row.name)));
|
||||||
const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name)));
|
const sourceNames = new Set(
|
||||||
|
bundle.database.tables.map((table) => String(table.name)),
|
||||||
|
);
|
||||||
for (const row of targetTables) {
|
for (const row of targetTables) {
|
||||||
if (!sourceNames.has(String(row.name))) {
|
if (!sourceNames.has(String(row.name))) {
|
||||||
throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`);
|
throw new Error(
|
||||||
}
|
`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const sourceOnlyTables = bundle.database.tables
|
const sourceOnlyTables = bundle.database.tables
|
||||||
.map((table) => String(table.name))
|
.map((table) => String(table.name))
|
||||||
.filter((name) => !targetNames.has(name));
|
.filter((name) => !targetNames.has(name));
|
||||||
if (sourceOnlyTables.length) {
|
if (sourceOnlyTables.length) {
|
||||||
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
|
throw new Error(
|
||||||
|
`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
const quoteIdentifier = (identifier) =>
|
||||||
|
`"${String(identifier).replaceAll('"', '""')}"`;
|
||||||
let restoredRows = 0;
|
let restoredRows = 0;
|
||||||
await database.exec('PRAGMA foreign_keys=OFF');
|
await database.exec('PRAGMA foreign_keys=OFF');
|
||||||
await database.exec('BEGIN');
|
await database.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
for (const row of [...targetTables].reverse()) {
|
for (const row of [...targetTables].reverse()) {
|
||||||
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
|
||||||
}
|
}
|
||||||
for (const table of bundle.database.tables) {
|
for (const table of bundle.database.tables) {
|
||||||
for (const row of table.rows) {
|
for (const row of table.rows) {
|
||||||
const columns = Object.keys(row);
|
const columns = Object.keys(row);
|
||||||
if (!columns.length) continue;
|
if (!columns.length) continue;
|
||||||
const insert = await database.prepare(
|
const insert = await database.prepare(
|
||||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
|
||||||
VALUES (${columns.map(() => '?').join(', ')})`
|
VALUES (${columns.map(() => '?').join(', ')})`,
|
||||||
);
|
);
|
||||||
await insert.run(...columns.map((column) => row[column]));
|
await insert.run(...columns.map((column) => row[column]));
|
||||||
restoredRows++;
|
restoredRows++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
await database.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await database.exec('PRAGMA foreign_keys=ON');
|
await database.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars');
|
const avatarDirectory = resolve(
|
||||||
const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
|
process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars',
|
||||||
|
);
|
||||||
|
const gameIconDirectory = resolve(
|
||||||
|
process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons',
|
||||||
|
);
|
||||||
await mkdir(avatarDirectory, { recursive: true });
|
await mkdir(avatarDirectory, { recursive: true });
|
||||||
await mkdir(gameIconDirectory, { recursive: true });
|
await mkdir(gameIconDirectory, { recursive: true });
|
||||||
for (const file of bundle.files.avatars) {
|
for (const file of bundle.files.avatars) {
|
||||||
await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(
|
||||||
|
join(avatarDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (const file of bundle.files.gameIcons) {
|
for (const file of bundle.files.gameIcons) {
|
||||||
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
|
await writeFile(
|
||||||
|
join(gameIconDirectory, file.name),
|
||||||
|
Buffer.from(file.contentBase64, 'base64'),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`);
|
console.log(
|
||||||
|
`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`,
|
||||||
|
);
|
||||||
console.log(`Source bundle: ${bundlePath}`);
|
console.log(`Source bundle: ${bundlePath}`);
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import process from 'node:process';
|
|
||||||
|
|
||||||
const stagingFile = resolve('.env.staging');
|
|
||||||
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
|
|
||||||
const sharedKeys = [
|
|
||||||
'DISCORD_CLIENT_ID',
|
|
||||||
'DISCORD_CLIENT_SECRET',
|
|
||||||
'SMTP_HOST',
|
|
||||||
'SMTP_PORT',
|
|
||||||
'SMTP_SECURE',
|
|
||||||
'SMTP_USER',
|
|
||||||
'SMTP_PASSWORD',
|
|
||||||
'SMTP_FROM'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of sharedKeys) {
|
|
||||||
if (!process.env[key]) throw new Error(`Source environment is missing ${key}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dotenvx(action) {
|
|
||||||
const result = spawnSync(process.execPath, [dotenvxCli, action, '-f', stagingFile], {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdio: 'inherit'
|
|
||||||
});
|
|
||||||
if (result.error) throw result.error;
|
|
||||||
if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
dotenvx('decrypt');
|
|
||||||
try {
|
|
||||||
let contents = await readFile(stagingFile, 'utf8');
|
|
||||||
for (const key of sharedKeys) {
|
|
||||||
const replacement = `${key}=${JSON.stringify(process.env[key])}`;
|
|
||||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
|
||||||
if (!pattern.test(contents)) throw new Error(`Staging environment is missing ${key}.`);
|
|
||||||
contents = contents.replace(pattern, replacement);
|
|
||||||
}
|
|
||||||
await writeFile(stagingFile, contents, 'utf8');
|
|
||||||
} finally {
|
|
||||||
dotenvx('encrypt');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Copied shared Discord and SMTP credentials into encrypted staging configuration.');
|
|
||||||
@@ -3,64 +3,102 @@ import nodemailer from 'nodemailer';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'));
|
const database = await connect(
|
||||||
|
resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'),
|
||||||
|
);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
const due = await (
|
||||||
|
await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
||||||
FROM automated_system_message_deliveries delivery
|
FROM automated_system_message_deliveries delivery
|
||||||
JOIN automated_system_messages message ON message.id=delivery.message_id
|
JOIN automated_system_messages message ON message.id=delivery.message_id
|
||||||
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
||||||
ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
|
ORDER BY delivery.scheduled_for LIMIT 250`)
|
||||||
|
).all(now);
|
||||||
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM);
|
||||||
const transport = smtpConfigured ? nodemailer.createTransport({
|
const transport = smtpConfigured
|
||||||
host: process.env.SMTP_HOST,
|
? nodemailer.createTransport({
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
host: process.env.SMTP_HOST,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined,
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
requireTLS: process.env.SMTP_SECURE !== 'true',
|
auth: process.env.SMTP_USER
|
||||||
disableFileAccess: true,
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
|
||||||
disableUrlAccess: true
|
: undefined,
|
||||||
}) : null;
|
requireTLS: process.env.SMTP_SECURE !== 'true',
|
||||||
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
disableFileAccess: true,
|
||||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
disableUrlAccess: true,
|
||||||
'&': '&',
|
})
|
||||||
'<': '<',
|
: null;
|
||||||
'>': '>',
|
const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(
|
||||||
'"': '"',
|
/\/+$/,
|
||||||
"'": '''
|
'',
|
||||||
})[character]);
|
);
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
String(value).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(character) =>
|
||||||
|
({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[character],
|
||||||
|
);
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
|
|
||||||
for (const message of due) {
|
for (const message of due) {
|
||||||
const notificationId = randomUUID();
|
const notificationId = randomUUID();
|
||||||
await (await database.prepare(`INSERT INTO notifications
|
await (
|
||||||
|
await database.prepare(`INSERT INTO notifications
|
||||||
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
||||||
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
|
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)
|
||||||
notificationId, message.user_id, message.source, message.title, message.body,
|
).run(
|
||||||
message.link ?? null, message.actions_json ?? null, now
|
notificationId,
|
||||||
);
|
message.user_id,
|
||||||
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
message.source,
|
||||||
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
message.title,
|
||||||
.run(now, notificationId, message.delivery_id);
|
message.body,
|
||||||
const preference = await (await database.prepare(
|
message.link ?? null,
|
||||||
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
message.actions_json ?? null,
|
||||||
)).get(message.user_id);
|
now,
|
||||||
if (transport && preference?.email_frequency === 'immediate') {
|
);
|
||||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
|
await (
|
||||||
if (typeof identity?.email === 'string') {
|
await database.prepare(`UPDATE automated_system_message_deliveries
|
||||||
try {
|
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`)
|
||||||
await transport.sendMail({
|
).run(now, notificationId, message.delivery_id);
|
||||||
from: process.env.SMTP_FROM,
|
const preference = await (
|
||||||
to: identity.email,
|
await database.prepare(
|
||||||
subject: `SHD.build · ${message.title}`,
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'",
|
||||||
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
)
|
||||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
|
).get(message.user_id);
|
||||||
});
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
const identity = await (
|
||||||
} catch (error) {
|
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||||
console.error(`Unable to email automated notification ${notificationId}`, error);
|
).get(message.user_id);
|
||||||
}
|
if (typeof identity?.email === 'string') {
|
||||||
}
|
try {
|
||||||
}
|
await transport.sendMail({
|
||||||
delivered += 1;
|
from: process.env.SMTP_FROM,
|
||||||
|
to: identity.email,
|
||||||
|
subject: `SHD.build · ${message.title}`,
|
||||||
|
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`,
|
||||||
|
});
|
||||||
|
await (
|
||||||
|
await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=?',
|
||||||
|
)
|
||||||
|
).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Unable to email automated notification ${notificationId}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += 1;
|
||||||
}
|
}
|
||||||
console.log(`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`);
|
console.log(
|
||||||
|
`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -4,37 +4,41 @@ import { join, resolve } from 'node:path';
|
|||||||
|
|
||||||
const iconDirectory = resolve('./static');
|
const iconDirectory = resolve('./static');
|
||||||
const iconFiles = (await readdir(iconDirectory))
|
const iconFiles = (await readdir(iconDirectory))
|
||||||
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
.filter((file) => file.toLocaleLowerCase().endsWith('.png'))
|
||||||
.sort((left, right) => left.localeCompare(right));
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
|
||||||
for (const file of iconFiles) {
|
for (const file of iconFiles) {
|
||||||
const source = join(iconDirectory, file);
|
const source = join(iconDirectory, file);
|
||||||
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
const temporary = join(iconDirectory, `.${file}.trimmed`);
|
||||||
const before = await sharp(source).metadata();
|
const before = await sharp(source).metadata();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sharp(source)
|
const result = await sharp(source)
|
||||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
.extend({
|
.extend({
|
||||||
top: 2,
|
top: 2,
|
||||||
right: 2,
|
right: 2,
|
||||||
bottom: 2,
|
bottom: 2,
|
||||||
left: 2,
|
left: 2,
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
})
|
})
|
||||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||||
.toFile(temporary);
|
.toFile(temporary);
|
||||||
|
|
||||||
if (!result.width || !result.height || result.channels !== 4) {
|
if (!result.width || !result.height || result.channels !== 4) {
|
||||||
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
throw new Error(`${file} did not produce a valid transparent RGBA icon.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rename(temporary, source);
|
await rename(temporary, source);
|
||||||
console.log(`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`);
|
console.log(
|
||||||
} catch (error) {
|
`${file}: ${before.width}x${before.height} -> ${result.width}x${result.height}`,
|
||||||
await unlink(temporary).catch(() => {});
|
);
|
||||||
throw error;
|
} catch (error) {
|
||||||
}
|
await unlink(temporary).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`);
|
console.log(
|
||||||
|
`Trimmed ${iconFiles.length} static PNG icons with a 2px transparent safety margin.`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import process from 'node:process';
|
||||||
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
if (mode !== 'app' && mode !== 'scheduler') {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/validate-runtime-environment.mjs <app|scheduler>',
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateRuntimeEnvironment(mode, process.env);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Invalid ${mode} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
+18995
-2063
File diff suppressed because one or more lines are too long
Vendored
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Error {
|
interface Error {
|
||||||
message: string;
|
message: string;
|
||||||
errorId?: string;
|
errorId?: string;
|
||||||
}
|
}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
+9
-9
@@ -1,12 +1,12 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+137
-9
@@ -1,12 +1,140 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>%sveltekit.status% · SHD.build system fault</title>
|
<title>%sveltekit.status% · SHD.build system fault</title>
|
||||||
<style>
|
<style>
|
||||||
:root{color-scheme:dark;font-family:Arial,Helvetica,sans-serif;background:#0a0c0d;color:#ecebe5}*{box-sizing:border-box}body{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:linear-gradient(135deg,#101314 0,#0a0c0d 55%)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(#ffffff14 1px,transparent 1px),linear-gradient(90deg,#ffffff14 1px,transparent 1px);background-size:48px 48px}main{position:relative;width:min(760px,100%);border:1px solid #3b4142;border-top:2px solid #df8069;background:#111415}header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid #303536}header div{display:grid;gap:4px}header span{color:#858b8d;font-size:8px;font-weight:900;letter-spacing:1.5px}header strong{color:#df8069;font-size:11px;letter-spacing:1px}header b{font-size:32px;color:#df8069}section{padding:clamp(28px,6vw,58px)}p{color:#858b8d;font:15px/1.6 Georgia,serif}small{display:block;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.8px}h1{max-width:620px;margin:14px 0;font-size:clamp(34px,7vw,62px);line-height:.92;letter-spacing:-2px}a{display:inline-block;margin-top:20px;padding:14px 18px;background:#f6a821;color:#111;font-size:9px;font-weight:900;letter-spacing:1.2px;text-decoration:none}footer{display:flex;align-items:center;gap:8px;padding:12px 20px;border-top:1px solid #303536;color:#697174;font-size:7px;letter-spacing:1px}footer i{width:6px;height:6px;background:#df8069;box-shadow:0 0 7px #df8069}
|
:root {
|
||||||
</style>
|
color-scheme: dark;
|
||||||
</head>
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
<body><main><header><div><span>ISAC FALLBACK CHANNEL</span><strong>CORE SHELL INTERRUPTED</strong></div><b>%sveltekit.status%</b></header><section><small>SHD NETWORK RESPONSE</small><h1>SYSTEM INTERFACE UNAVAILABLE</h1><p>%sveltekit.error.message%</p><a href="/">RETURN TO SHD.BUILD →</a></section><footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer></main></body>
|
background: #0a0c0d;
|
||||||
|
color: #ecebe5;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #101314 0, #0a0c0d 55%);
|
||||||
|
}
|
||||||
|
body:before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.08;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(#ffffff14 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, #ffffff14 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
position: relative;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
border: 1px solid #3b4142;
|
||||||
|
border-top: 2px solid #df8069;
|
||||||
|
background: #111415;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid #303536;
|
||||||
|
}
|
||||||
|
header div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
header span {
|
||||||
|
color: #858b8d;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
header strong {
|
||||||
|
color: #df8069;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
header b {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #df8069;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
padding: clamp(28px, 6vw, 58px);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #858b8d;
|
||||||
|
font:
|
||||||
|
15px/1.6 Georgia,
|
||||||
|
serif;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
color: #f6a821;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1.8px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 14px 0;
|
||||||
|
font-size: clamp(34px, 7vw, 62px);
|
||||||
|
line-height: 0.92;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #f6a821;
|
||||||
|
color: #111;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #303536;
|
||||||
|
color: #697174;
|
||||||
|
font-size: 7px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
footer i {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #df8069;
|
||||||
|
box-shadow: 0 0 7px #df8069;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span>ISAC FALLBACK CHANNEL</span
|
||||||
|
><strong>CORE SHELL INTERRUPTED</strong>
|
||||||
|
</div>
|
||||||
|
<b>%sveltekit.status%</b>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<small>SHD NETWORK RESPONSE</small>
|
||||||
|
<h1>SYSTEM INTERFACE UNAVAILABLE</h1>
|
||||||
|
<p>%sveltekit.error.message%</p>
|
||||||
|
<a href="/">RETURN TO SHD.BUILD →</a>
|
||||||
|
</section>
|
||||||
|
<footer><i></i> FAULT ISOLATED · CORE SERVICES PROTECTED</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+143
-81
@@ -2,89 +2,151 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
|||||||
import { stringify } from 'devalue';
|
import { stringify } from 'devalue';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getSiteAccessBlock, getSiteAccessSettings } from '$lib/server/site-access';
|
import {
|
||||||
import { DATA_MODULES, proposalActionResult, queueDataProposal, shouldModerateDataMutation, type DataModule } from '$lib/server/data-moderation';
|
getSiteAccessBlock,
|
||||||
|
getSiteAccessSettings,
|
||||||
|
} from '$lib/server/site-access';
|
||||||
|
import {
|
||||||
|
DATA_MODULES,
|
||||||
|
proposalActionResult,
|
||||||
|
queueDataProposal,
|
||||||
|
shouldModerateDataMutation,
|
||||||
|
type DataModule,
|
||||||
|
} from '$lib/server/data-moderation';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname === '/admin' || event.url.pathname.startsWith('/admin/')) {
|
if (
|
||||||
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
|
event.url.pathname === '/admin' ||
|
||||||
return new Response(null, { status: 308, headers: { location } });
|
event.url.pathname.startsWith('/admin/')
|
||||||
}
|
) {
|
||||||
const siteSettings = await getSiteAccessSettings();
|
const location = `/isac${event.url.pathname.slice('/admin'.length)}${event.url.search}`;
|
||||||
const sessionUser = await getSessionUser(event.cookies);
|
return new Response(null, { status: 308, headers: { location } });
|
||||||
const accessBlock = getSiteAccessBlock(siteSettings, event.url.pathname, Boolean(sessionUser));
|
}
|
||||||
if (accessBlock) {
|
const siteSettings = await getSiteAccessSettings();
|
||||||
const target = new URL('/coming-soon', event.url);
|
const sessionUser = await getSessionUser(event.cookies);
|
||||||
if (accessBlock.kind === 'section') target.searchParams.set('unavailable', accessBlock.section);
|
const accessBlock = getSiteAccessBlock(
|
||||||
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
|
siteSettings,
|
||||||
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
event.url.pathname,
|
||||||
}
|
Boolean(sessionUser),
|
||||||
return json(
|
);
|
||||||
{
|
if (accessBlock) {
|
||||||
error: accessBlock.kind === 'section'
|
const target = new URL('/coming-soon', event.url);
|
||||||
? 'This section is not available.'
|
if (accessBlock.kind === 'section')
|
||||||
: 'The public site is not available yet.'
|
target.searchParams.set('unavailable', accessBlock.section);
|
||||||
},
|
if (event.request.method === 'GET' || event.request.method === 'HEAD') {
|
||||||
{ status: 404 }
|
return new Response(null, {
|
||||||
);
|
status: 303,
|
||||||
}
|
headers: { location: `${target.pathname}${target.search}` },
|
||||||
if (event.request.method === 'POST') {
|
});
|
||||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
}
|
||||||
const module = match?.[1] as DataModule | undefined;
|
return json(
|
||||||
const action = event.url.search.startsWith('?/') ? event.url.search.slice(2) : '';
|
{
|
||||||
if (module && DATA_MODULES.includes(module) && action) {
|
error:
|
||||||
// Publishing calculation rules is never a contributor proposal. The
|
accessBlock.kind === 'section'
|
||||||
// action has its own dedicated permission and confirmation contract.
|
? 'This section is not available.'
|
||||||
if (module === 'calculations' && action === 'publishRevision') return resolve(event);
|
: 'The public site is not available yet.',
|
||||||
const user = sessionUser;
|
},
|
||||||
const access = await shouldModerateDataMutation(user);
|
{ status: 404 },
|
||||||
if (!access.canManage && access.canPropose && user) {
|
);
|
||||||
if (!access.contributorEnabled) {
|
}
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
if (event.request.method === 'POST') {
|
||||||
return json({ type: 'failure', status: 403, data: { contributorModeRequired: true } }, { status: 403 });
|
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||||
}
|
const module = match?.[1] as DataModule | undefined;
|
||||||
return new Response('Contributor mode must be enabled before submitting data changes.', { status: 403 });
|
const action = event.url.search.startsWith('?/')
|
||||||
}
|
? event.url.search.slice(2)
|
||||||
const submittedForm = await event.request.formData();
|
: '';
|
||||||
const proposal = await queueDataProposal(user, module, action, submittedForm);
|
if (module && DATA_MODULES.includes(module) && action) {
|
||||||
if (event.request.headers.get('accept')?.includes('application/json')) {
|
// Publishing calculation rules is never a contributor proposal. The
|
||||||
const result = proposalActionResult(module, action, Boolean(proposal.entityId), proposal.id);
|
// action has its own dedicated permission and confirmation contract.
|
||||||
if (module === 'attributes' && action === 'save') {
|
if (module === 'calculations' && action === 'publishRevision')
|
||||||
Object.assign(result, {
|
return resolve(event);
|
||||||
savedItem: {
|
const user = sessionUser;
|
||||||
id: proposal.entityId ?? proposal.draftEntityId,
|
const access = await shouldModerateDataMutation(user);
|
||||||
name: String(submittedForm.get('name') ?? ''),
|
if (!access.canManage && access.canPropose && user) {
|
||||||
type: String(submittedForm.get('type') ?? ''),
|
if (!access.contributorEnabled) {
|
||||||
category: String(submittedForm.get('category') ?? ''),
|
if (
|
||||||
calculation_type: String(submittedForm.get('calculationType') ?? ''),
|
event.request.headers.get('accept')?.includes('application/json')
|
||||||
unit: String(submittedForm.get('unit') ?? ''),
|
) {
|
||||||
active: 1,
|
return json(
|
||||||
pending: true,
|
{
|
||||||
proposalId: proposal.id,
|
type: 'failure',
|
||||||
proposerName: user.displayName
|
status: 403,
|
||||||
}
|
data: { contributorModeRequired: true },
|
||||||
});
|
},
|
||||||
}
|
{ status: 403 },
|
||||||
return json({
|
);
|
||||||
type: 'success',
|
}
|
||||||
status: 200,
|
return new Response(
|
||||||
data: stringify(result)
|
'Contributor mode must be enabled before submitting data changes.',
|
||||||
});
|
{ status: 403 },
|
||||||
}
|
);
|
||||||
const target = new URL(event.url.pathname, event.url);
|
}
|
||||||
target.searchParams.set('proposal', proposal.id);
|
const submittedForm = await event.request.formData();
|
||||||
return new Response(null, { status: 303, headers: { location: `${target.pathname}${target.search}` } });
|
const proposal = await queueDataProposal(
|
||||||
}
|
user,
|
||||||
}
|
module,
|
||||||
}
|
action,
|
||||||
return resolve(event);
|
submittedForm,
|
||||||
|
);
|
||||||
|
if (event.request.headers.get('accept')?.includes('application/json')) {
|
||||||
|
const result = proposalActionResult(
|
||||||
|
module,
|
||||||
|
action,
|
||||||
|
Boolean(proposal.entityId),
|
||||||
|
proposal.id,
|
||||||
|
);
|
||||||
|
if (module === 'attributes' && action === 'save') {
|
||||||
|
Object.assign(result, {
|
||||||
|
savedItem: {
|
||||||
|
id: proposal.entityId ?? proposal.draftEntityId,
|
||||||
|
name: String(submittedForm.get('name') ?? ''),
|
||||||
|
type: String(submittedForm.get('type') ?? ''),
|
||||||
|
category: String(submittedForm.get('category') ?? ''),
|
||||||
|
calculation_type: String(
|
||||||
|
submittedForm.get('calculationType') ?? '',
|
||||||
|
),
|
||||||
|
unit: String(submittedForm.get('unit') ?? ''),
|
||||||
|
active: 1,
|
||||||
|
pending: true,
|
||||||
|
proposalId: proposal.id,
|
||||||
|
proposerName: user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json({
|
||||||
|
type: 'success',
|
||||||
|
status: 200,
|
||||||
|
data: stringify(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = new URL(event.url.pathname, event.url);
|
||||||
|
target.searchParams.set('proposal', proposal.id);
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: { location: `${target.pathname}${target.search}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleError: HandleServerError = ({ error, event, status, message }) => {
|
export const handleError: HandleServerError = ({
|
||||||
const errorId = randomUUID().slice(0, 8).toUpperCase();
|
error,
|
||||||
console.error(`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`, error);
|
event,
|
||||||
return {
|
status,
|
||||||
message: status >= 500 ? 'An unexpected fault interrupted this operation. No further action was applied.' : message,
|
message,
|
||||||
errorId
|
}) => {
|
||||||
};
|
const errorId = randomUUID().slice(0, 8).toUpperCase();
|
||||||
|
console.error(
|
||||||
|
`[${errorId}] ${event.request.method} ${event.url.pathname} -> ${status}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
status >= 500
|
||||||
|
? 'An unexpected fault interrupted this operation. No further action was applied.'
|
||||||
|
: message,
|
||||||
|
errorId,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+49
-34
@@ -1,50 +1,65 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
agentProfileHref,
|
agentProfileHref,
|
||||||
displayNameKey,
|
displayNameKey,
|
||||||
newAgentDisplayName,
|
newAgentDisplayName,
|
||||||
publicProfileSlugBase
|
publicProfileSlugBase,
|
||||||
} from './agents.js';
|
} from './agents.js';
|
||||||
|
|
||||||
describe('display names', () => {
|
describe('display names', () => {
|
||||||
it('uses one normalized key for uniqueness checks', () => {
|
it('uses one normalized key for uniqueness checks', () => {
|
||||||
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
|
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
|
||||||
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
|
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates readable short-UID placeholders', () => {
|
it('creates readable short-UID placeholders', () => {
|
||||||
expect(newAgentDisplayName(47)).toBe('Agent 47');
|
expect(newAgentDisplayName(47)).toBe('Agent 47');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('publicProfileSlugBase', () => {
|
describe('publicProfileSlugBase', () => {
|
||||||
it('creates readable normalized slugs', () => {
|
it('creates readable normalized slugs', () => {
|
||||||
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
|
expect(
|
||||||
.toBe('aewyn-prime');
|
publicProfileSlugBase(
|
||||||
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
|
' Aewyn Prime ',
|
||||||
.toBe('élite-装備');
|
'12345678-0000-4000-8000-000000000000',
|
||||||
});
|
),
|
||||||
|
).toBe('aewyn-prime');
|
||||||
|
expect(
|
||||||
|
publicProfileSlugBase(
|
||||||
|
'Élite 装備',
|
||||||
|
'12345678-0000-4000-8000-000000000000',
|
||||||
|
),
|
||||||
|
).toBe('élite-装備');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a stable fallback when a name has no letters or numbers', () => {
|
it('uses a stable fallback when a name has no letters or numbers', () => {
|
||||||
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
|
expect(
|
||||||
.toBe('agent-12345678');
|
publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'),
|
||||||
});
|
).toBe('agent-12345678');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
|
||||||
expect(publicProfileSlugBase(
|
expect(
|
||||||
'57a1160e-632f-4695-927f-7df03e20753f',
|
publicProfileSlugBase(
|
||||||
'12345678-0000-4000-8000-000000000000'
|
'57a1160e-632f-4695-927f-7df03e20753f',
|
||||||
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
|
'12345678-0000-4000-8000-000000000000',
|
||||||
});
|
),
|
||||||
|
).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('agentProfileHref', () => {
|
describe('agentProfileHref', () => {
|
||||||
it('prefers the public slug and safely encodes it', () => {
|
it('prefers the public slug and safely encodes it', () => {
|
||||||
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
|
expect(
|
||||||
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
|
agentProfileHref(
|
||||||
});
|
{ id: 'uuid', publicSlug: 'élite 装備' },
|
||||||
|
'profile-comments',
|
||||||
|
),
|
||||||
|
).toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to the UUID for unmigrated records', () => {
|
it('falls back to the UUID for unmigrated records', () => {
|
||||||
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
|
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-21
@@ -1,36 +1,40 @@
|
|||||||
export type AgentProfileReference = {
|
export type AgentProfileReference = {
|
||||||
id: string;
|
id: string;
|
||||||
publicSlug?: string | null;
|
publicSlug?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
export function displayNameKey(displayName: string): string {
|
export function displayNameKey(displayName: string): string {
|
||||||
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
|
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newAgentDisplayName(sequence: number): string {
|
export function newAgentDisplayName(sequence: number): string {
|
||||||
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicProfileSlugBase(displayName: string, userId: string): string {
|
export function publicProfileSlugBase(
|
||||||
const normalized = displayName
|
displayName: string,
|
||||||
.normalize('NFKC')
|
userId: string,
|
||||||
.toLocaleLowerCase('en-US')
|
): string {
|
||||||
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
const normalized = displayName
|
||||||
.replace(/^-+|-+$/g, '')
|
.normalize('NFKC')
|
||||||
.slice(0, 64)
|
.toLocaleLowerCase('en-US')
|
||||||
.replace(/-+$/g, '');
|
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
|
||||||
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
.replace(/^-+|-+$/g, '')
|
||||||
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
.slice(0, 64)
|
||||||
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
|
.replace(/-+$/g, '');
|
||||||
|
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
|
||||||
|
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
|
||||||
|
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function agentProfileHref(
|
export function agentProfileHref(
|
||||||
agent: AgentProfileReference,
|
agent: AgentProfileReference,
|
||||||
fragment = ''
|
fragment = '',
|
||||||
): string {
|
): string {
|
||||||
const identifier = agent.publicSlug || agent.id;
|
const identifier = agent.publicSlug || agent.id;
|
||||||
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
|
||||||
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
|
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +1,120 @@
|
|||||||
import type {
|
import type {
|
||||||
AiMechanicQuestion,
|
AiMechanicQuestion,
|
||||||
AiMechanicResponseMeta
|
AiMechanicResponseMeta,
|
||||||
} from './mechanic-authoring';
|
} from './mechanic-authoring';
|
||||||
|
|
||||||
export const AI_CALCULATION_STAGE_OPERATORS = [
|
export const AI_CALCULATION_STAGE_OPERATORS = [
|
||||||
'base_damage',
|
'base_damage',
|
||||||
'flat_add',
|
'flat_add',
|
||||||
'additive_percent',
|
'additive_percent',
|
||||||
'multiplicative_percent',
|
'multiplicative_percent',
|
||||||
'expected_critical',
|
'expected_critical',
|
||||||
'expected_headshot',
|
'expected_headshot',
|
||||||
'rate_of_fire',
|
'rate_of_fire',
|
||||||
'magazine_size',
|
'magazine_size',
|
||||||
'reload_speed',
|
'reload_speed',
|
||||||
'firing_cycle'
|
'firing_cycle',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const AI_SKILL_CALCULATION_STAGES = [
|
export const AI_SKILL_CALCULATION_STAGES = [
|
||||||
'engineering',
|
'engineering',
|
||||||
'flat_damage',
|
'flat_damage',
|
||||||
'skill_damage',
|
'skill_damage',
|
||||||
'final_damage',
|
'final_damage',
|
||||||
'critical_chance',
|
'critical_chance',
|
||||||
'critical_damage',
|
'critical_damage',
|
||||||
'multishot_chance',
|
'multishot_chance',
|
||||||
'multishot_damage',
|
'multishot_damage',
|
||||||
'cooldown_recovery',
|
'cooldown_recovery',
|
||||||
'duration'
|
'duration',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AiCalculationStageDraft = {
|
export type AiCalculationStageDraft = {
|
||||||
stageKey: string;
|
stageKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
|
operator: (typeof AI_CALCULATION_STAGE_OPERATORS)[number];
|
||||||
displayOrder: number;
|
displayOrder: number;
|
||||||
lockedPosition: 'first' | 'last' | null;
|
lockedPosition: 'first' | 'last' | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationProfileDraft = {
|
export type AiCalculationProfileDraft = {
|
||||||
statAttributeId: string;
|
statAttributeId: string;
|
||||||
stageKey: string;
|
stageKey: string;
|
||||||
weight: number;
|
weight: number;
|
||||||
softCap: number | null;
|
softCap: number | null;
|
||||||
hardCap: number | null;
|
hardCap: number | null;
|
||||||
curveType: 'none' | 'power' | 'smoothstep';
|
curveType: 'none' | 'power' | 'smoothstep';
|
||||||
curveParameter: number | null;
|
curveParameter: number | null;
|
||||||
weaponCategory: string;
|
weaponCategory: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiSkillCalculationProfileDraft = {
|
export type AiSkillCalculationProfileDraft = {
|
||||||
statAttributeId: string;
|
statAttributeId: string;
|
||||||
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
|
stageKey: (typeof AI_SKILL_CALCULATION_STAGES)[number];
|
||||||
operator: 'add' | 'multiply';
|
operator: 'add' | 'multiply';
|
||||||
weight: number;
|
weight: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationRevisionDraft = {
|
export type AiCalculationRevisionDraft = {
|
||||||
name: string;
|
name: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
stages: AiCalculationStageDraft[];
|
stages: AiCalculationStageDraft[];
|
||||||
profiles: AiCalculationProfileDraft[];
|
profiles: AiCalculationProfileDraft[];
|
||||||
skillProfiles: AiSkillCalculationProfileDraft[];
|
skillProfiles: AiSkillCalculationProfileDraft[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationTranscriptEntry = {
|
export type AiCalculationTranscriptEntry = {
|
||||||
questionId: string;
|
questionId: string;
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationAuthoringRequest = {
|
export type AiCalculationAuthoringRequest = {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
baseRevisionId: string | null;
|
baseRevisionId: string | null;
|
||||||
currentDraft: AiCalculationRevisionDraft;
|
currentDraft: AiCalculationRevisionDraft;
|
||||||
transcript: AiCalculationTranscriptEntry[];
|
transcript: AiCalculationTranscriptEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationQuestion = AiMechanicQuestion;
|
export type AiCalculationQuestion = AiMechanicQuestion;
|
||||||
|
|
||||||
export type AiCalculationCapabilityGap = {
|
export type AiCalculationCapabilityGap = {
|
||||||
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
|
area: 'stage_operator' | 'curve' | 'skill_stage' | 'attribute' | 'runtime';
|
||||||
requiredBehavior: string;
|
requiredBehavior: string;
|
||||||
whyUnsupported: string;
|
whyUnsupported: string;
|
||||||
developerChange: string;
|
developerChange: string;
|
||||||
workaround: string | null;
|
workaround: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationClarificationResponse = {
|
export type AiCalculationClarificationResponse = {
|
||||||
status: 'clarification_required';
|
status: 'clarification_required';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
questions: AiCalculationQuestion[];
|
questions: AiCalculationQuestion[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationDraftResponse = {
|
export type AiCalculationDraftResponse = {
|
||||||
status: 'draft_ready';
|
status: 'draft_ready';
|
||||||
draft: AiCalculationRevisionDraft;
|
draft: AiCalculationRevisionDraft;
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
pseudoScript: string;
|
pseudoScript: string;
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationCapabilityGapResponse = {
|
export type AiCalculationCapabilityGapResponse = {
|
||||||
status: 'capability_gap';
|
status: 'capability_gap';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
gaps: AiCalculationCapabilityGap[];
|
gaps: AiCalculationCapabilityGap[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCalculationAuthoringResponse =
|
export type AiCalculationAuthoringResponse =
|
||||||
| AiCalculationClarificationResponse
|
| AiCalculationClarificationResponse
|
||||||
| AiCalculationDraftResponse
|
| AiCalculationDraftResponse
|
||||||
| AiCalculationCapabilityGapResponse;
|
| AiCalculationCapabilityGapResponse;
|
||||||
|
|||||||
@@ -1,140 +1,143 @@
|
|||||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
|
|
||||||
export const AI_MECHANIC_SOURCE_KINDS = [
|
export const AI_MECHANIC_SOURCE_KINDS = [
|
||||||
'talent',
|
'talent',
|
||||||
'exotic_weapon_talent',
|
'exotic_weapon_talent',
|
||||||
'os_protocol_talent',
|
'os_protocol_talent',
|
||||||
'skill_chip_talent',
|
'skill_chip_talent',
|
||||||
'specialization_tree_rank'
|
'specialization_tree_rank',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
|
export type AiMechanicSourceKind = (typeof AI_MECHANIC_SOURCE_KINDS)[number];
|
||||||
|
|
||||||
export const AI_MECHANIC_SOURCE_PROFILES = {
|
export const AI_MECHANIC_SOURCE_PROFILES = {
|
||||||
talent: {
|
talent: {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
},
|
},
|
||||||
exotic_weapon_talent: {
|
exotic_weapon_talent: {
|
||||||
variantKeys: ['I', 'II'],
|
variantKeys: ['I', 'II'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
},
|
},
|
||||||
os_protocol_talent: {
|
os_protocol_talent: {
|
||||||
variantKeys: ['base'],
|
variantKeys: ['base'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: true
|
allowProtocolUpgradeScaling: true,
|
||||||
},
|
},
|
||||||
skill_chip_talent: {
|
skill_chip_talent: {
|
||||||
variantKeys: ['minimum', 'maximum'],
|
variantKeys: ['minimum', 'maximum'],
|
||||||
allowAssignedAbility: true,
|
allowAssignedAbility: true,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
},
|
},
|
||||||
specialization_tree_rank: {
|
specialization_tree_rank: {
|
||||||
variantKeys: ['base'],
|
variantKeys: ['base'],
|
||||||
allowAssignedAbility: false,
|
allowAssignedAbility: false,
|
||||||
allowProtocolUpgradeScaling: false
|
allowProtocolUpgradeScaling: false,
|
||||||
}
|
},
|
||||||
} as const satisfies Record<AiMechanicSourceKind, {
|
} as const satisfies Record<
|
||||||
variantKeys: readonly string[];
|
AiMechanicSourceKind,
|
||||||
allowAssignedAbility: boolean;
|
{
|
||||||
allowProtocolUpgradeScaling: boolean;
|
variantKeys: readonly string[];
|
||||||
}>;
|
allowAssignedAbility: boolean;
|
||||||
|
allowProtocolUpgradeScaling: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export type AiMechanicContext = {
|
export type AiMechanicContext = {
|
||||||
sourceKind: AiMechanicSourceKind;
|
sourceKind: AiMechanicSourceKind;
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
sourceDescription: string;
|
sourceDescription: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicValueColumn = {
|
export type AiMechanicValueColumn = {
|
||||||
key: string;
|
key: string;
|
||||||
variantKey: string;
|
variantKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicTranscriptEntry = {
|
export type AiMechanicTranscriptEntry = {
|
||||||
questionId: string;
|
questionId: string;
|
||||||
question: string;
|
question: string;
|
||||||
answer: string | number | boolean;
|
answer: string | number | boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicAuthoringRequest = {
|
export type AiMechanicAuthoringRequest = {
|
||||||
context: AiMechanicContext;
|
context: AiMechanicContext;
|
||||||
currentDraft: ConditionalMechanicDraftV1;
|
currentDraft: ConditionalMechanicDraftV1;
|
||||||
valueColumns: AiMechanicValueColumn[];
|
valueColumns: AiMechanicValueColumn[];
|
||||||
allowAssignedAbility: boolean;
|
allowAssignedAbility: boolean;
|
||||||
allowProtocolUpgradeScaling: boolean;
|
allowProtocolUpgradeScaling: boolean;
|
||||||
transcript: AiMechanicTranscriptEntry[];
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicQuestion = {
|
export type AiMechanicQuestion = {
|
||||||
id: string;
|
id: string;
|
||||||
question: string;
|
question: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
answerType: 'text' | 'number' | 'choice' | 'boolean';
|
answerType: 'text' | 'number' | 'choice' | 'boolean';
|
||||||
options: string[];
|
options: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicResponseMeta = {
|
export type AiMechanicResponseMeta = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
model: string;
|
model: string;
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicClarificationResponse = {
|
export type AiMechanicClarificationResponse = {
|
||||||
status: 'clarification_required';
|
status: 'clarification_required';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
questions: AiMechanicQuestion[];
|
questions: AiMechanicQuestion[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicDraftResponse = {
|
export type AiMechanicDraftResponse = {
|
||||||
status: 'draft_ready';
|
status: 'draft_ready';
|
||||||
draft: ConditionalMechanicDraftV1;
|
draft: ConditionalMechanicDraftV1;
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
pseudoScript: string;
|
pseudoScript: string;
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicCapabilityGap = {
|
export type AiMechanicCapabilityGap = {
|
||||||
area:
|
area:
|
||||||
| 'event'
|
| 'event'
|
||||||
| 'predicate'
|
| 'predicate'
|
||||||
| 'reducer'
|
| 'reducer'
|
||||||
| 'mutation'
|
| 'mutation'
|
||||||
| 'outcome'
|
| 'outcome'
|
||||||
| 'scope'
|
| 'scope'
|
||||||
| 'value_reference'
|
| 'value_reference'
|
||||||
| 'source_variant'
|
| 'source_variant'
|
||||||
| 'runtime';
|
| 'runtime';
|
||||||
requiredBehavior: string;
|
requiredBehavior: string;
|
||||||
whyUnsupported: string;
|
whyUnsupported: string;
|
||||||
developerChange: string;
|
developerChange: string;
|
||||||
workaround: string | null;
|
workaround: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicCapabilityGapResponse = {
|
export type AiMechanicCapabilityGapResponse = {
|
||||||
status: 'capability_gap';
|
status: 'capability_gap';
|
||||||
summary: string;
|
summary: string;
|
||||||
assumptions: string[];
|
assumptions: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
gaps: AiMechanicCapabilityGap[];
|
gaps: AiMechanicCapabilityGap[];
|
||||||
meta: AiMechanicResponseMeta;
|
meta: AiMechanicResponseMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiMechanicAuthoringResponse =
|
export type AiMechanicAuthoringResponse =
|
||||||
| AiMechanicClarificationResponse
|
| AiMechanicClarificationResponse
|
||||||
| AiMechanicDraftResponse
|
| AiMechanicDraftResponse
|
||||||
| AiMechanicCapabilityGapResponse;
|
| AiMechanicCapabilityGapResponse;
|
||||||
|
|
||||||
export type AiMechanicAuthoringError = {
|
export type AiMechanicAuthoringError = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,66 +1,74 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildMechanicDeveloperPrompt, type AiMechanicDeveloperHandoff } from './mechanic-developer-handoff';
|
import {
|
||||||
|
buildMechanicDeveloperPrompt,
|
||||||
|
type AiMechanicDeveloperHandoff,
|
||||||
|
} from './mechanic-developer-handoff';
|
||||||
|
|
||||||
const handoff: AiMechanicDeveloperHandoff = {
|
const handoff: AiMechanicDeveloperHandoff = {
|
||||||
context: {
|
context: {
|
||||||
sourceKind: 'talent',
|
sourceKind: 'talent',
|
||||||
sourceName: 'Chain Reaction',
|
sourceName: 'Chain Reaction',
|
||||||
sourceDescription: 'Every third critical hit transfers the mark to a nearby enemy.'
|
sourceDescription:
|
||||||
},
|
'Every third critical hit transfers the mark to a nearby enemy.',
|
||||||
valueColumns: [
|
},
|
||||||
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
valueColumns: [
|
||||||
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
|
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
||||||
],
|
{ key: 'tierII', variantKey: 'II', label: 'TIER II' },
|
||||||
allowAssignedAbility: false,
|
],
|
||||||
allowProtocolUpgradeScaling: false,
|
allowAssignedAbility: false,
|
||||||
transcript: [{
|
allowProtocolUpgradeScaling: false,
|
||||||
questionId: 'transfer-target',
|
transcript: [
|
||||||
question: 'How is the next target selected?',
|
{
|
||||||
answer: 'Nearest enemy'
|
questionId: 'transfer-target',
|
||||||
}],
|
question: 'How is the next target selected?',
|
||||||
response: {
|
answer: 'Nearest enemy',
|
||||||
status: 'capability_gap',
|
},
|
||||||
summary: 'The runtime cannot select a nearby transfer target.',
|
],
|
||||||
assumptions: ['The third hit consumes the current mark.'],
|
response: {
|
||||||
warnings: ['Target selection must be deterministic.'],
|
status: 'capability_gap',
|
||||||
gaps: [{
|
summary: 'The runtime cannot select a nearby transfer target.',
|
||||||
area: 'runtime',
|
assumptions: ['The third hit consumes the current mark.'],
|
||||||
requiredBehavior: 'Select the nearest eligible enemy.',
|
warnings: ['Target selection must be deterministic.'],
|
||||||
whyUnsupported: 'The event has no ordered nearby-target collection.',
|
gaps: [
|
||||||
developerChange: 'Add a deterministic nearby-target selector.',
|
{
|
||||||
workaround: null
|
area: 'runtime',
|
||||||
}],
|
requiredBehavior: 'Select the nearest eligible enemy.',
|
||||||
meta: {
|
whyUnsupported: 'The event has no ordered nearby-target collection.',
|
||||||
requestId: 'req_gap_123',
|
developerChange: 'Add a deterministic nearby-target selector.',
|
||||||
model: 'test-model',
|
workaround: null,
|
||||||
inputTokens: 100,
|
},
|
||||||
outputTokens: 50,
|
],
|
||||||
totalTokens: 150
|
meta: {
|
||||||
}
|
requestId: 'req_gap_123',
|
||||||
}
|
model: 'test-model',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('mechanic developer handoff prompt', () => {
|
describe('mechanic developer handoff prompt', () => {
|
||||||
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
|
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
|
||||||
const prompt = buildMechanicDeveloperPrompt(handoff);
|
const prompt = buildMechanicDeveloperPrompt(handoff);
|
||||||
|
|
||||||
expect(prompt).toContain(handoff.context.sourceDescription);
|
expect(prompt).toContain(handoff.context.sourceDescription);
|
||||||
expect(prompt).toContain('How is the next target selected?');
|
expect(prompt).toContain('How is the next target selected?');
|
||||||
expect(prompt).toContain('Answer: Nearest enemy');
|
expect(prompt).toContain('Answer: Nearest enemy');
|
||||||
expect(prompt).toContain('Add a deterministic nearby-target selector.');
|
expect(prompt).toContain('Add a deterministic nearby-target selector.');
|
||||||
expect(prompt).toContain('Assistant request ID: req_gap_123');
|
expect(prompt).toContain('Assistant request ID: req_gap_123');
|
||||||
expect(prompt).toContain('Persistence round-trips the new representation');
|
expect(prompt).toContain('Persistence round-trips the new representation');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits empty optional sections', () => {
|
it('omits empty optional sections', () => {
|
||||||
const prompt = buildMechanicDeveloperPrompt({
|
const prompt = buildMechanicDeveloperPrompt({
|
||||||
...handoff,
|
...handoff,
|
||||||
transcript: [],
|
transcript: [],
|
||||||
response: { ...handoff.response, assumptions: [], warnings: [] }
|
response: { ...handoff.response, assumptions: [], warnings: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).not.toContain('## Clarification history');
|
expect(prompt).not.toContain('## Clarification history');
|
||||||
expect(prompt).not.toContain('## Assumptions');
|
expect(prompt).not.toContain('## Assumptions');
|
||||||
expect(prompt).not.toContain('## Warnings');
|
expect(prompt).not.toContain('## Warnings');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,55 @@
|
|||||||
import type {
|
import type {
|
||||||
AiMechanicCapabilityGapResponse,
|
AiMechanicCapabilityGapResponse,
|
||||||
AiMechanicContext,
|
AiMechanicContext,
|
||||||
AiMechanicTranscriptEntry,
|
AiMechanicTranscriptEntry,
|
||||||
AiMechanicValueColumn
|
AiMechanicValueColumn,
|
||||||
} from '$lib/ai/mechanic-authoring';
|
} from '$lib/ai/mechanic-authoring';
|
||||||
|
|
||||||
export type AiMechanicDeveloperHandoff = {
|
export type AiMechanicDeveloperHandoff = {
|
||||||
context: AiMechanicContext;
|
context: AiMechanicContext;
|
||||||
valueColumns: AiMechanicValueColumn[];
|
valueColumns: AiMechanicValueColumn[];
|
||||||
allowAssignedAbility: boolean;
|
allowAssignedAbility: boolean;
|
||||||
allowProtocolUpgradeScaling: boolean;
|
allowProtocolUpgradeScaling: boolean;
|
||||||
transcript: AiMechanicTranscriptEntry[];
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
response: AiMechanicCapabilityGapResponse;
|
response: AiMechanicCapabilityGapResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
function section(title: string, items: string[]) {
|
function section(title: string, items: string[]) {
|
||||||
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
|
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMechanicDeveloperPrompt(handoff: AiMechanicDeveloperHandoff) {
|
export function buildMechanicDeveloperPrompt(
|
||||||
const { context, valueColumns, allowAssignedAbility, allowProtocolUpgradeScaling, transcript, response } = handoff;
|
handoff: AiMechanicDeveloperHandoff,
|
||||||
const variants = valueColumns.map((column) => `${column.label} (${column.variantKey})`).join(', ');
|
) {
|
||||||
const clarifications = transcript.map((entry) =>
|
const {
|
||||||
`- ${entry.question}\n Answer: ${String(entry.answer)}`
|
context,
|
||||||
);
|
valueColumns,
|
||||||
const assumptions = response.assumptions.map((value) => `- ${value}`);
|
allowAssignedAbility,
|
||||||
const warnings = response.warnings.map((value) => `- ${value}`);
|
allowProtocolUpgradeScaling,
|
||||||
const gaps = response.gaps.map((gap, index) => [
|
transcript,
|
||||||
`### Gap ${index + 1}: ${gap.area}`,
|
response,
|
||||||
`- Required behavior: ${gap.requiredBehavior}`,
|
} = handoff;
|
||||||
`- Why unsupported: ${gap.whyUnsupported}`,
|
const variants = valueColumns
|
||||||
`- Developer change: ${gap.developerChange}`,
|
.map((column) => `${column.label} (${column.variantKey})`)
|
||||||
gap.workaround ? `- Current workaround: ${gap.workaround}` : '- Current workaround: None'
|
.join(', ');
|
||||||
].join('\n'));
|
const clarifications = transcript.map(
|
||||||
|
(entry) => `- ${entry.question}\n Answer: ${String(entry.answer)}`,
|
||||||
|
);
|
||||||
|
const assumptions = response.assumptions.map((value) => `- ${value}`);
|
||||||
|
const warnings = response.warnings.map((value) => `- ${value}`);
|
||||||
|
const gaps = response.gaps.map((gap, index) =>
|
||||||
|
[
|
||||||
|
`### Gap ${index + 1}: ${gap.area}`,
|
||||||
|
`- Required behavior: ${gap.requiredBehavior}`,
|
||||||
|
`- Why unsupported: ${gap.whyUnsupported}`,
|
||||||
|
`- Developer change: ${gap.developerChange}`,
|
||||||
|
gap.workaround
|
||||||
|
? `- Current workaround: ${gap.workaround}`
|
||||||
|
: '- Current workaround: None',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
|
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
|
||||||
|
|
||||||
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
|
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { EMPTY_ARTICLE_DOCUMENT, parseArticleDocument } from './document';
|
||||||
|
|
||||||
|
describe('article documents', () => {
|
||||||
|
it('accepts formatted prose, multiple Builds, and article images', () => {
|
||||||
|
const parsed = parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Opening', marks: [{ type: 'bold' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Use this rotation.',
|
||||||
|
marks: [
|
||||||
|
{ type: 'link', attrs: { href: 'https://shd.build/guides' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'buildEmbed',
|
||||||
|
attrs: {
|
||||||
|
buildId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
embedKey: 'build-11111111-1111-4111-8111-111111111111',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'buildEmbed',
|
||||||
|
attrs: {
|
||||||
|
buildId: '22222222-2222-4222-8222-222222222222',
|
||||||
|
embedKey: 'build-22222222-2222-4222-8222-222222222222',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'articleImage',
|
||||||
|
attrs: {
|
||||||
|
imageId: '33333333-3333-4333-8333-333333333333',
|
||||||
|
alt: 'A loadout screen',
|
||||||
|
caption: 'Final configuration',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed?.buildEmbeds).toHaveLength(2);
|
||||||
|
expect(parsed?.imageIds).toEqual(['33333333-3333-4333-8333-333333333333']);
|
||||||
|
expect(parsed?.plainText).toContain('Opening Use this rotation.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe links and unknown nodes', () => {
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'unsafe',
|
||||||
|
marks: [
|
||||||
|
{ type: 'link', attrs: { href: 'javascript:alert(1)' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'iframe', attrs: { src: 'https://example.com' } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
|
||||||
|
const parsed = parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Read more',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://shd.build/news',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer nofollow',
|
||||||
|
class: null,
|
||||||
|
title: 'News archive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 1, type: null },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'First item' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed?.document.document.content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Read more',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://shd.build/news',
|
||||||
|
title: 'News archive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'First item' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parsed?.plainText).toContain('News archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves supported ordered-list numbering', () => {
|
||||||
|
const parsed = parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 2, type: 'A' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'listItem',
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed?.document.document.content?.[0]).toMatchObject({
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 2, type: 'A' },
|
||||||
|
});
|
||||||
|
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
|
||||||
|
parsed?.document,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown link metadata and non-default ordered lists', () => {
|
||||||
|
const documentWith = (content: unknown[]) => ({
|
||||||
|
version: 1,
|
||||||
|
document: { type: 'doc', content },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseArticleDocument(
|
||||||
|
documentWith([
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'unsafe metadata',
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: 'https://shd.build',
|
||||||
|
onclick: 'alert(1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument(
|
||||||
|
documentWith([
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 0, type: 'decimal' },
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument(
|
||||||
|
documentWith([
|
||||||
|
{
|
||||||
|
type: 'orderedList',
|
||||||
|
attrs: { start: 1, type: 1 },
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate embed keys and unknown envelope fields', () => {
|
||||||
|
const embed = {
|
||||||
|
type: 'buildEmbed',
|
||||||
|
attrs: {
|
||||||
|
buildId: '11111111-1111-4111-8111-111111111111',
|
||||||
|
embedKey: 'build-11111111-1111-4111-8111-111111111111',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({
|
||||||
|
version: 1,
|
||||||
|
document: { type: 'doc', content: [embed, embed] },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseArticleDocument({ ...EMPTY_ARTICLE_DOCUMENT, legacy: true }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
export const ARTICLE_DOCUMENT_VERSION = 1 as const;
|
||||||
|
export const MAX_ARTICLE_DOCUMENT_BYTES = 240_000;
|
||||||
|
export const MAX_ARTICLE_TEXT_CHARACTERS = 60_000;
|
||||||
|
export const MAX_ARTICLE_NODES = 1_200;
|
||||||
|
export const MAX_ARTICLE_DEPTH = 8;
|
||||||
|
|
||||||
|
const ID_PATTERN =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
|
||||||
|
|
||||||
|
export type ArticleMark = {
|
||||||
|
type: 'bold' | 'italic' | 'code' | 'link';
|
||||||
|
attrs?: { href: string; title?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticleNode = {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
content?: ArticleNode[];
|
||||||
|
marks?: ArticleMark[];
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticleDocument = {
|
||||||
|
version: typeof ARTICLE_DOCUMENT_VERSION;
|
||||||
|
document: ArticleNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArticleBuildEmbed = {
|
||||||
|
embedKey: string;
|
||||||
|
buildId: string;
|
||||||
|
ordinal: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_ARTICLE_DOCUMENT: ArticleDocument = {
|
||||||
|
version: ARTICLE_DOCUMENT_VERSION,
|
||||||
|
document: { type: 'doc', content: [{ type: 'paragraph' }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
|
||||||
|
const exactKeys = (value: Record<string, unknown>, allowed: string[]) =>
|
||||||
|
Object.keys(value).every((key) => allowed.includes(key));
|
||||||
|
|
||||||
|
const safeLink = (value: unknown) => {
|
||||||
|
if (typeof value !== 'string' || value.length > 2_000) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) ? url.toString() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nullableBoundedString = (value: unknown, maximum: number) =>
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
(typeof value === 'string' &&
|
||||||
|
value.length <= maximum &&
|
||||||
|
!/\p{Cc}/u.test(value));
|
||||||
|
|
||||||
|
function parseMarks(
|
||||||
|
value: unknown,
|
||||||
|
onLinkTitle: (title: string) => void,
|
||||||
|
): ArticleMark[] | null {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!Array.isArray(value) || value.length > 4) return null;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const marks: ArticleMark[] = [];
|
||||||
|
for (const candidate of value) {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate) ||
|
||||||
|
typeof candidate.type !== 'string' ||
|
||||||
|
seen.has(candidate.type)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
seen.add(candidate.type);
|
||||||
|
if (['bold', 'italic', 'code'].includes(candidate.type)) {
|
||||||
|
if (!exactKeys(candidate, ['type'])) return null;
|
||||||
|
marks.push({ type: candidate.type as 'bold' | 'italic' | 'code' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
candidate.type !== 'link' ||
|
||||||
|
!exactKeys(candidate, ['type', 'attrs']) ||
|
||||||
|
!isRecord(candidate.attrs)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate.attrs, [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'class',
|
||||||
|
'title',
|
||||||
|
]) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.target, 100) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.rel, 500) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.class, 500) ||
|
||||||
|
!nullableBoundedString(candidate.attrs.title, 500)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const href = safeLink(candidate.attrs.href);
|
||||||
|
if (!href) return null;
|
||||||
|
const title =
|
||||||
|
typeof candidate.attrs.title === 'string'
|
||||||
|
? candidate.attrs.title.normalize('NFKC').trim()
|
||||||
|
: '';
|
||||||
|
if (title) onLinkTitle(title);
|
||||||
|
marks.push({
|
||||||
|
type: 'link',
|
||||||
|
attrs: { href, ...(title ? { title } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArticleDocument(value: unknown): {
|
||||||
|
document: ArticleDocument;
|
||||||
|
plainText: string;
|
||||||
|
buildEmbeds: ArticleBuildEmbed[];
|
||||||
|
imageIds: string[];
|
||||||
|
links: string[];
|
||||||
|
} | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (new TextEncoder().encode(value).byteLength > MAX_ARTICLE_DOCUMENT_BYTES)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isRecord(value) ||
|
||||||
|
!exactKeys(value, ['version', 'document']) ||
|
||||||
|
value.version !== ARTICLE_DOCUMENT_VERSION
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
let nodeCount = 0;
|
||||||
|
let textLength = 0;
|
||||||
|
let embedOrdinal = 0;
|
||||||
|
const textParts: string[] = [];
|
||||||
|
const buildEmbeds: ArticleBuildEmbed[] = [];
|
||||||
|
const imageIds: string[] = [];
|
||||||
|
const embedKeys = new Set<string>();
|
||||||
|
|
||||||
|
const parseNode = (candidate: unknown, depth: number): ArticleNode | null => {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate) ||
|
||||||
|
depth > MAX_ARTICLE_DEPTH ||
|
||||||
|
++nodeCount > MAX_ARTICLE_NODES
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (typeof candidate.type !== 'string') return null;
|
||||||
|
const type = candidate.type;
|
||||||
|
if (type === 'text') {
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate, ['type', 'text', 'marks']) ||
|
||||||
|
typeof candidate.text !== 'string'
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const text = candidate.text.normalize('NFKC');
|
||||||
|
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
|
||||||
|
textLength += [...text].length;
|
||||||
|
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
|
||||||
|
const marks = parseMarks(candidate.marks, (title) =>
|
||||||
|
textParts.push(title),
|
||||||
|
);
|
||||||
|
if (!marks) return null;
|
||||||
|
textParts.push(text);
|
||||||
|
return marks.length ? { type, text, marks } : { type, text };
|
||||||
|
}
|
||||||
|
if (type === 'buildEmbed') {
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate, ['type', 'attrs']) ||
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['buildId', 'embedKey'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const buildId = String(candidate.attrs.buildId ?? '');
|
||||||
|
const embedKey = String(candidate.attrs.embedKey ?? '');
|
||||||
|
if (
|
||||||
|
!ID_PATTERN.test(buildId) ||
|
||||||
|
!EMBED_KEY_PATTERN.test(embedKey) ||
|
||||||
|
embedKeys.has(embedKey)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
embedKeys.add(embedKey);
|
||||||
|
buildEmbeds.push({ buildId, embedKey, ordinal: embedOrdinal++ });
|
||||||
|
textParts.push('[Embedded Build]');
|
||||||
|
return { type, attrs: { buildId, embedKey } };
|
||||||
|
}
|
||||||
|
if (type === 'articleImage') {
|
||||||
|
if (
|
||||||
|
!exactKeys(candidate, ['type', 'attrs']) ||
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['imageId', 'alt', 'caption'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const imageId = String(candidate.attrs.imageId ?? '');
|
||||||
|
const alt = String(candidate.attrs.alt ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.trim();
|
||||||
|
const caption = String(candidate.attrs.caption ?? '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.trim();
|
||||||
|
if (
|
||||||
|
!ID_PATTERN.test(imageId) ||
|
||||||
|
!alt ||
|
||||||
|
[...alt].length > 300 ||
|
||||||
|
[...caption].length > 500
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
imageIds.push(imageId);
|
||||||
|
textParts.push(alt, caption);
|
||||||
|
return { type, attrs: { imageId, alt, caption } };
|
||||||
|
}
|
||||||
|
const leafTypes = ['horizontalRule', 'hardBreak'];
|
||||||
|
if (leafTypes.includes(type)) {
|
||||||
|
return exactKeys(candidate, ['type']) ? { type } : null;
|
||||||
|
}
|
||||||
|
const containerTypes = [
|
||||||
|
'doc',
|
||||||
|
'paragraph',
|
||||||
|
'heading',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'listItem',
|
||||||
|
'blockquote',
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!containerTypes.includes(type) ||
|
||||||
|
!exactKeys(candidate, ['type', 'attrs', 'content'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
let attrs: Record<string, unknown> | undefined;
|
||||||
|
if (type === 'heading') {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['level']) ||
|
||||||
|
![2, 3].includes(Number(candidate.attrs.level))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
attrs = { level: Number(candidate.attrs.level) };
|
||||||
|
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
|
||||||
|
if (
|
||||||
|
!isRecord(candidate.attrs) ||
|
||||||
|
!exactKeys(candidate.attrs, ['start', 'type'])
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const start = candidate.attrs.start ?? 1;
|
||||||
|
const listType = candidate.attrs.type;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(start) ||
|
||||||
|
Number(start) < 1 ||
|
||||||
|
Number(start) > 1_000_000 ||
|
||||||
|
(listType !== null &&
|
||||||
|
listType !== undefined &&
|
||||||
|
(typeof listType !== 'string' ||
|
||||||
|
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const canonicalType =
|
||||||
|
typeof listType === 'string' && listType !== '1' ? listType : undefined;
|
||||||
|
attrs = {
|
||||||
|
...(Number(start) === 1 ? {} : { start: Number(start) }),
|
||||||
|
...(canonicalType ? { type: canonicalType } : {}),
|
||||||
|
};
|
||||||
|
if (!Object.keys(attrs).length) attrs = undefined;
|
||||||
|
} else if (candidate.attrs !== undefined) {
|
||||||
|
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (candidate.content !== undefined && !Array.isArray(candidate.content))
|
||||||
|
return null;
|
||||||
|
const content: ArticleNode[] = [];
|
||||||
|
for (const child of candidate.content ?? []) {
|
||||||
|
const parsed = parseNode(child, depth + 1);
|
||||||
|
if (!parsed) return null;
|
||||||
|
content.push(parsed);
|
||||||
|
}
|
||||||
|
if (['paragraph', 'heading', 'listItem', 'blockquote'].includes(type))
|
||||||
|
textParts.push('\n');
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
...(attrs ? { attrs } : {}),
|
||||||
|
...(content.length ? { content } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const document = parseNode(value.document, 0);
|
||||||
|
if (!document || document.type !== 'doc') return null;
|
||||||
|
const parsed: ArticleDocument = {
|
||||||
|
version: ARTICLE_DOCUMENT_VERSION,
|
||||||
|
document,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
new TextEncoder().encode(JSON.stringify(parsed)).byteLength >
|
||||||
|
MAX_ARTICLE_DOCUMENT_BYTES
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const links: string[] = [];
|
||||||
|
const collectLinks = (node: ArticleNode) => {
|
||||||
|
for (const mark of node.marks ?? [])
|
||||||
|
if (mark.type === 'link' && mark.attrs?.href) links.push(mark.attrs.href);
|
||||||
|
for (const child of node.content ?? []) collectLinks(child);
|
||||||
|
};
|
||||||
|
collectLinks(document);
|
||||||
|
return {
|
||||||
|
document: parsed,
|
||||||
|
plainText: textParts.join(' ').replace(/\s+/g, ' ').trim(),
|
||||||
|
buildEmbeds,
|
||||||
|
imageIds: [...new Set(imageIds)],
|
||||||
|
links: [...new Set(links)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleModerationText(input: {
|
||||||
|
title: string;
|
||||||
|
deck: string;
|
||||||
|
plainText: string;
|
||||||
|
coverAlt?: string;
|
||||||
|
coverCaption?: string;
|
||||||
|
links?: string[];
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
input.title,
|
||||||
|
input.deck,
|
||||||
|
input.coverAlt,
|
||||||
|
input.coverCaption,
|
||||||
|
input.plainText,
|
||||||
|
...(input.links ?? []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
|
||||||
|
export const BuildEmbedNode = Node.create({
|
||||||
|
name: 'buildEmbed',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
buildId: { default: null },
|
||||||
|
embedKey: { default: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'article[data-build-embed]' }];
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'article',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-build-embed': '',
|
||||||
|
class: 'article-editor-embed',
|
||||||
|
contenteditable: 'false',
|
||||||
|
}),
|
||||||
|
['span', {}, 'EMBEDDED BUILD'],
|
||||||
|
['strong', {}, String(HTMLAttributes.buildId ?? 'BUILD')],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ArticleImageNode = Node.create({
|
||||||
|
name: 'articleImage',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
imageId: { default: null },
|
||||||
|
alt: { default: '' },
|
||||||
|
caption: { default: '' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'figure[data-article-image]' }];
|
||||||
|
},
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const imageId = String(HTMLAttributes.imageId ?? '');
|
||||||
|
return [
|
||||||
|
'figure',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-article-image': '',
|
||||||
|
class: 'article-editor-image',
|
||||||
|
contenteditable: 'false',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'img',
|
||||||
|
{
|
||||||
|
src: `/images/library/${encodeURIComponent(imageId)}`,
|
||||||
|
alt: String(HTMLAttributes.alt ?? ''),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'figcaption',
|
||||||
|
{},
|
||||||
|
String(HTMLAttributes.caption || HTMLAttributes.alt || 'ARTICLE IMAGE'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
+81
-21
@@ -1,29 +1,89 @@
|
|||||||
export const AVATAR_PRESETS = [
|
export const AVATAR_PRESETS = [
|
||||||
{ id: 'field-agent', label: 'Field Agent', url: '/avatars/default/field-agent.webp' },
|
{
|
||||||
{ id: 'field-medic', label: 'Field Medic', url: '/avatars/default/field-medic.webp' },
|
id: 'field-agent',
|
||||||
{ id: 'systems-engineer', label: 'Systems Engineer', url: '/avatars/default/systems-engineer.webp' },
|
label: 'Field Agent',
|
||||||
{ id: 'recon-scout', label: 'Recon Scout', url: '/avatars/default/recon-scout.webp' },
|
url: '/avatars/default/field-agent.webp',
|
||||||
{ id: 'drone-pilot', label: 'Drone Pilot', url: '/avatars/default/drone-pilot.webp' },
|
},
|
||||||
{ id: 'field-commander', label: 'Field Commander', url: '/avatars/default/field-commander.webp' },
|
{
|
||||||
{ id: 'rescue-specialist', label: 'Rescue Specialist', url: '/avatars/default/rescue-specialist.webp' },
|
id: 'field-medic',
|
||||||
{ id: 'equipment-engineer', label: 'Equipment Engineer', url: '/avatars/default/equipment-engineer.webp' },
|
label: 'Field Medic',
|
||||||
{ id: 'wilderness-tracker', label: 'Wilderness Tracker', url: '/avatars/default/wilderness-tracker.webp' },
|
url: '/avatars/default/field-medic.webp',
|
||||||
{ id: 'intelligence-analyst', label: 'Intelligence Analyst', url: '/avatars/default/intelligence-analyst.webp' },
|
},
|
||||||
{ id: 'combat-medic', label: 'Combat Medic', url: '/avatars/default/combat-medic.webp' },
|
{
|
||||||
{ id: 'comms-specialist', label: 'Comms Specialist', url: '/avatars/default/comms-specialist.webp' }
|
id: 'systems-engineer',
|
||||||
|
label: 'Systems Engineer',
|
||||||
|
url: '/avatars/default/systems-engineer.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recon-scout',
|
||||||
|
label: 'Recon Scout',
|
||||||
|
url: '/avatars/default/recon-scout.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drone-pilot',
|
||||||
|
label: 'Drone Pilot',
|
||||||
|
url: '/avatars/default/drone-pilot.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-commander',
|
||||||
|
label: 'Field Commander',
|
||||||
|
url: '/avatars/default/field-commander.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rescue-specialist',
|
||||||
|
label: 'Rescue Specialist',
|
||||||
|
url: '/avatars/default/rescue-specialist.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'equipment-engineer',
|
||||||
|
label: 'Equipment Engineer',
|
||||||
|
url: '/avatars/default/equipment-engineer.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wilderness-tracker',
|
||||||
|
label: 'Wilderness Tracker',
|
||||||
|
url: '/avatars/default/wilderness-tracker.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'intelligence-analyst',
|
||||||
|
label: 'Intelligence Analyst',
|
||||||
|
url: '/avatars/default/intelligence-analyst.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'combat-medic',
|
||||||
|
label: 'Combat Medic',
|
||||||
|
url: '/avatars/default/combat-medic.webp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comms-specialist',
|
||||||
|
label: 'Comms Specialist',
|
||||||
|
url: '/avatars/default/comms-specialist.webp',
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
|
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
|
||||||
|
|
||||||
export function isAvatarPreset(value: string): value is (typeof AVATAR_PRESETS)[number]['id'] {
|
export function isAvatarPreset(
|
||||||
return AVATAR_PRESETS.some((preset) => preset.id === value);
|
value: string,
|
||||||
|
): value is (typeof AVATAR_PRESETS)[number]['id'] {
|
||||||
|
return AVATAR_PRESETS.some((preset) => preset.id === value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function avatarUrl(source: unknown, value: unknown, userId: string, updatedAt?: unknown): string {
|
export function avatarUrl(
|
||||||
if (source === 'upload') {
|
source: unknown,
|
||||||
const version = updatedAt ? `?v=${encodeURIComponent(String(updatedAt))}` : '';
|
value: unknown,
|
||||||
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
|
userId: string,
|
||||||
}
|
updatedAt?: unknown,
|
||||||
const preset = typeof value === 'string' && isAvatarPreset(value) ? value : DEFAULT_AVATAR_PRESET;
|
): string {
|
||||||
return `/avatars/default/${preset}.webp`;
|
if (source === 'upload') {
|
||||||
|
const version = updatedAt
|
||||||
|
? `?v=${encodeURIComponent(String(updatedAt))}`
|
||||||
|
: '';
|
||||||
|
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
|
||||||
|
}
|
||||||
|
const preset =
|
||||||
|
typeof value === 'string' && isAvatarPreset(value)
|
||||||
|
? value
|
||||||
|
: DEFAULT_AVATAR_PRESET;
|
||||||
|
return `/avatars/default/${preset}.webp`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildShareUrl, copyBuildLink, shareBuild } from './build-sharing';
|
||||||
|
|
||||||
|
describe('Build sharing', () => {
|
||||||
|
it('constructs Build URLs from slugs and preserves absolute URLs', () => {
|
||||||
|
expect(buildShareUrl('field-medic', 'https://shd.build')).toBe(
|
||||||
|
'https://shd.build/builds/field-medic',
|
||||||
|
);
|
||||||
|
expect(buildShareUrl('build name', 'https://shd.build/ignored')).toBe(
|
||||||
|
'https://shd.build/builds/build%20name',
|
||||||
|
);
|
||||||
|
expect(buildShareUrl('https://stage.shd.build/builds/test')).toBe(
|
||||||
|
'https://stage.shd.build/builds/test',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the native share API', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shareBuild(
|
||||||
|
{ share },
|
||||||
|
{ title: 'Field Medic', url: 'https://shd.build/builds/field-medic' },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'shared' });
|
||||||
|
expect(share).toHaveBeenCalledWith({
|
||||||
|
title: 'Field Medic',
|
||||||
|
text: 'View Field Medic on SHD.build.',
|
||||||
|
url: 'https://shd.build/builds/field-medic',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats AbortError as cancellation', async () => {
|
||||||
|
const share = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shareBuild(
|
||||||
|
{ share },
|
||||||
|
{ title: 'Build', url: 'https://shd.build/builds/a' },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'cancelled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported and failed native sharing', async () => {
|
||||||
|
await expect(
|
||||||
|
shareBuild({}, { title: 'Build', url: 'https://shd.build/builds/a' }),
|
||||||
|
).resolves.toEqual({ state: 'unsupported' });
|
||||||
|
|
||||||
|
const error = new Error('Share failed');
|
||||||
|
await expect(
|
||||||
|
shareBuild(
|
||||||
|
{ share: vi.fn().mockRejectedValue(error) },
|
||||||
|
{ title: 'Build', url: 'https://shd.build/builds/a' },
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'failed', error });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the Build URL to the clipboard', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
copyBuildLink({ clipboard: { writeText } }, 'https://shd.build/builds/a'),
|
||||||
|
).resolves.toEqual({ state: 'copied' });
|
||||||
|
expect(writeText).toHaveBeenCalledWith('https://shd.build/builds/a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported and failed clipboard access', async () => {
|
||||||
|
await expect(
|
||||||
|
copyBuildLink({}, 'https://shd.build/builds/a'),
|
||||||
|
).resolves.toEqual({
|
||||||
|
state: 'unsupported',
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = new Error('Clipboard denied');
|
||||||
|
await expect(
|
||||||
|
copyBuildLink(
|
||||||
|
{ clipboard: { writeText: vi.fn().mockRejectedValue(error) } },
|
||||||
|
'https://shd.build/builds/a',
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ state: 'failed', error });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export type BuildShareNavigator = {
|
||||||
|
share?: (data: ShareData) => Promise<void>;
|
||||||
|
clipboard?: {
|
||||||
|
writeText: (text: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildShareResult =
|
||||||
|
| { state: 'shared' }
|
||||||
|
| { state: 'cancelled' }
|
||||||
|
| { state: 'unsupported' }
|
||||||
|
| { state: 'failed'; error: unknown };
|
||||||
|
|
||||||
|
export type BuildCopyResult =
|
||||||
|
| { state: 'copied' }
|
||||||
|
| { state: 'unsupported' }
|
||||||
|
| { state: 'failed'; error: unknown };
|
||||||
|
|
||||||
|
export function buildShareUrl(slugOrUrl: string, origin?: string): string {
|
||||||
|
const value = slugOrUrl.trim();
|
||||||
|
if (!value) throw new Error('A Build slug or URL is required.');
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(value)) return new URL(value).toString();
|
||||||
|
|
||||||
|
const base = origin ?? globalThis.location?.origin;
|
||||||
|
if (!base) throw new Error('An origin is required to construct a Build URL.');
|
||||||
|
|
||||||
|
const slug = value.replace(/^\/+|\/+$/g, '');
|
||||||
|
return new URL(`/builds/${encodeURIComponent(slug)}`, base).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareBuild(
|
||||||
|
navigatorApi: BuildShareNavigator,
|
||||||
|
input: { title: string; url: string },
|
||||||
|
): Promise<BuildShareResult> {
|
||||||
|
if (typeof navigatorApi.share !== 'function') return { state: 'unsupported' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigatorApi.share({
|
||||||
|
title: input.title,
|
||||||
|
text: `View ${input.title} on SHD.build.`,
|
||||||
|
url: input.url,
|
||||||
|
});
|
||||||
|
return { state: 'shared' };
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'name' in error &&
|
||||||
|
error.name === 'AbortError'
|
||||||
|
) {
|
||||||
|
return { state: 'cancelled' };
|
||||||
|
}
|
||||||
|
return { state: 'failed', error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyBuildLink(
|
||||||
|
navigatorApi: BuildShareNavigator,
|
||||||
|
url: string,
|
||||||
|
): Promise<BuildCopyResult> {
|
||||||
|
if (typeof navigatorApi.clipboard?.writeText !== 'function') {
|
||||||
|
return { state: 'unsupported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigatorApi.clipboard.writeText(url);
|
||||||
|
return { state: 'copied' };
|
||||||
|
} catch (error) {
|
||||||
|
return { state: 'failed', error };
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1337
-1238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createDraft } from './loadout.js';
|
||||||
|
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
||||||
|
|
||||||
|
const attribute = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
options: {
|
||||||
|
unit?: string;
|
||||||
|
calculationType?: string;
|
||||||
|
cap?: number | null;
|
||||||
|
} = {},
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
category: 'Combat',
|
||||||
|
unit: options.unit ?? '',
|
||||||
|
calculation_type: options.calculationType ?? 'Additive',
|
||||||
|
cap_value: options.cap ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
|
||||||
|
attributes: [],
|
||||||
|
gear: [],
|
||||||
|
gearAugments: [],
|
||||||
|
brands: [],
|
||||||
|
weapons: [],
|
||||||
|
talents: [],
|
||||||
|
chipSets: [],
|
||||||
|
protocols: [],
|
||||||
|
specializations: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
||||||
|
summary.stats.find((entry) => entry.id === id);
|
||||||
|
|
||||||
|
describe('summarizeLoadout', () => {
|
||||||
|
it('returns an empty deterministic summary for a partial draft', () => {
|
||||||
|
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||||
|
stats: [],
|
||||||
|
abilityStats: [],
|
||||||
|
activeBonuses: [],
|
||||||
|
equippedTalents: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores saved rolls that do not reference a known Attribute', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.weapons[0].itemId = 'weapon';
|
||||||
|
draft.weapons[0].rolls[0] = { statId: 'unknown', value: 999 };
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
weapons: [
|
||||||
|
{
|
||||||
|
id: 'weapon',
|
||||||
|
name: 'Test Weapon',
|
||||||
|
exotic_talent_id: null,
|
||||||
|
baseBonuses: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.stats).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines additive and multiplicative Attributes, applies caps, and counts distinct sources', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.weapons[0].itemId = 'weapon';
|
||||||
|
draft.weapons[0].rolls = [
|
||||||
|
{ statId: 'additive', value: 5 },
|
||||||
|
{ statId: 'multiplicative', value: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('additive', 'Additive'),
|
||||||
|
attribute('multiplicative', 'Multiplicative', {
|
||||||
|
unit: '%',
|
||||||
|
calculationType: 'Multiplicative',
|
||||||
|
}),
|
||||||
|
attribute('capped', 'Capped', { cap: 25 }),
|
||||||
|
],
|
||||||
|
weapons: [
|
||||||
|
{
|
||||||
|
id: 'weapon',
|
||||||
|
name: 'Test Weapon',
|
||||||
|
exotic_talent_id: null,
|
||||||
|
baseBonuses: [
|
||||||
|
{ stat_attribute_id: 'additive', value: 10 },
|
||||||
|
{ stat_attribute_id: 'multiplicative', value: 10 },
|
||||||
|
{ stat_attribute_id: 'capped', value: 30 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'additive')).toEqual({
|
||||||
|
id: 'additive',
|
||||||
|
value: 15,
|
||||||
|
sourceCount: 2,
|
||||||
|
});
|
||||||
|
expect(stat(summary, 'multiplicative')?.value).toBeCloseTo(32);
|
||||||
|
expect(stat(summary, 'multiplicative')?.sourceCount).toBe(2);
|
||||||
|
expect(stat(summary, 'capped')).toEqual({
|
||||||
|
id: 'capped',
|
||||||
|
value: 25,
|
||||||
|
sourceCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates every earned set bonus and records it as a stat source', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
for (const slot of ['Holster', 'Gloves'] as const) {
|
||||||
|
draft.gear[slot].itemId = `${slot}-gear`;
|
||||||
|
draft.gear[slot].brandId = 'brand';
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [attribute('set-stat', 'Set Stat', { unit: '%' })],
|
||||||
|
gear: [
|
||||||
|
{ id: 'Holster-gear', tier: 0, base_armor_min: null },
|
||||||
|
{ id: 'Gloves-gear', tier: 0, base_armor_min: null },
|
||||||
|
],
|
||||||
|
brands: [
|
||||||
|
{
|
||||||
|
id: 'brand',
|
||||||
|
name: 'Determinist',
|
||||||
|
effects: [
|
||||||
|
{
|
||||||
|
pieces_required: 1,
|
||||||
|
stat_attribute_id: 'set-stat',
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pieces_required: 2,
|
||||||
|
stat_attribute_id: 'set-stat',
|
||||||
|
value: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'set-stat')).toEqual({
|
||||||
|
id: 'set-stat',
|
||||||
|
value: 15,
|
||||||
|
sourceCount: 2,
|
||||||
|
});
|
||||||
|
expect(summary.activeBonuses).toEqual([
|
||||||
|
expect.objectContaining({ pieces: 1, count: 2, value: 5 }),
|
||||||
|
expect.objectContaining({ pieces: 2, count: 2, value: 10 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interpolates Skill Chip bonuses and Talent descriptions from High-end count', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
draft.chips[index].setId = 'chip-set';
|
||||||
|
draft.chips[index].quality = index === 0 ? 'High-end' : 'Superior';
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [attribute('skill-damage', 'Skill Damage', { unit: '%' })],
|
||||||
|
chipSets: [
|
||||||
|
{
|
||||||
|
id: 'chip-set',
|
||||||
|
name: 'Signal',
|
||||||
|
specialization_ability_id: 'ability',
|
||||||
|
twoPiece: {
|
||||||
|
stat_attribute_id: 'skill-damage',
|
||||||
|
min_value: 10,
|
||||||
|
max_value: 40,
|
||||||
|
},
|
||||||
|
talent: {
|
||||||
|
name: 'Signal Talent',
|
||||||
|
description_template: 'Gain {1} Skill Damage.',
|
||||||
|
effects: [
|
||||||
|
{
|
||||||
|
stat_attribute_id: 'skill-damage',
|
||||||
|
min_value: 15,
|
||||||
|
max_value: 45,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rule: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'skill-damage')).toEqual({
|
||||||
|
id: 'skill-damage',
|
||||||
|
value: 20,
|
||||||
|
sourceCount: 1,
|
||||||
|
});
|
||||||
|
expect(summary.equippedTalents).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'chips:chip-set:ability',
|
||||||
|
tier: 1,
|
||||||
|
abilityId: 'ability',
|
||||||
|
description: 'Gain 25% Skill Damage.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.specialization.focusId = 'focus-1';
|
||||||
|
draft.chips[0] = {
|
||||||
|
setId: 'chip-set',
|
||||||
|
quality: 'Superior',
|
||||||
|
primary: { statId: 'skill-duration', value: 12 },
|
||||||
|
secondary: [
|
||||||
|
{ statId: 'agent-stat', value: 4 },
|
||||||
|
{ statId: 'other-secondary', value: 6 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
|
||||||
|
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
|
||||||
|
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
|
||||||
|
],
|
||||||
|
chipSets: [
|
||||||
|
{
|
||||||
|
id: 'chip-set',
|
||||||
|
name: 'Signal',
|
||||||
|
specialization_ability_id: 'ability-2',
|
||||||
|
compatibleAbilityIds: ['ability-2', 'ability-1'],
|
||||||
|
twoPiece: null,
|
||||||
|
talent: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
specializations: [
|
||||||
|
{
|
||||||
|
id: 'specialization-1',
|
||||||
|
focuses: [
|
||||||
|
{
|
||||||
|
id: 'focus-1',
|
||||||
|
abilities: [{ id: 'ability-1' }],
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'skill-duration')).toBeUndefined();
|
||||||
|
expect(stat(summary, 'agent-stat')?.value).toBe(4);
|
||||||
|
expect(stat(summary, 'other-secondary')?.value).toBe(6);
|
||||||
|
expect(summary.abilityStats).toEqual([
|
||||||
|
{
|
||||||
|
abilityId: 'ability-1',
|
||||||
|
stats: [
|
||||||
|
{
|
||||||
|
id: 'skill-duration',
|
||||||
|
value: 12,
|
||||||
|
sourceCount: 1,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
source: 'Skill Chip 1 primary',
|
||||||
|
chipIndex: 0,
|
||||||
|
chipSetId: 'chip-set',
|
||||||
|
value: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a shared Skill Chip after its ability link is removed', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.specialization.focusId = 'focus-1';
|
||||||
|
draft.chips[0] = {
|
||||||
|
setId: 'chip-set',
|
||||||
|
quality: 'Standard',
|
||||||
|
primary: { statId: 'skill-duration', value: 12 },
|
||||||
|
secondary: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
|
||||||
|
],
|
||||||
|
chipSets: [
|
||||||
|
{
|
||||||
|
id: 'chip-set',
|
||||||
|
name: 'Signal',
|
||||||
|
specialization_ability_id: 'ability-2',
|
||||||
|
compatibleAbilityIds: ['ability-2'],
|
||||||
|
twoPiece: null,
|
||||||
|
talent: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
specializations: [
|
||||||
|
{
|
||||||
|
id: 'specialization-1',
|
||||||
|
focuses: [
|
||||||
|
{
|
||||||
|
id: 'focus-1',
|
||||||
|
abilities: [{ id: 'ability-1' }],
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.abilityStats).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, 10],
|
||||||
|
[1, 20],
|
||||||
|
[2, 30],
|
||||||
|
[3, 40],
|
||||||
|
])(
|
||||||
|
'scales a three-chip 2-piece bonus with %i High-end chips',
|
||||||
|
(highEndCount, expectedValue) => {
|
||||||
|
const draft = createDraft();
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
draft.chips[index].setId = 'chip-set';
|
||||||
|
draft.chips[index].quality =
|
||||||
|
index < highEndCount ? 'High-end' : 'Standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
|
||||||
|
],
|
||||||
|
chipSets: [
|
||||||
|
{
|
||||||
|
id: 'chip-set',
|
||||||
|
name: 'Signal',
|
||||||
|
specialization_ability_id: 'ability',
|
||||||
|
twoPiece: {
|
||||||
|
stat_attribute_id: 'skill-damage',
|
||||||
|
min_value: 10,
|
||||||
|
max_value: 40,
|
||||||
|
},
|
||||||
|
talent: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
|
||||||
|
expect(summary.activeBonuses[0]).toMatchObject({
|
||||||
|
count: 3,
|
||||||
|
value: expectedValue,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('scales Protocol primary stats and Talent descriptions independently', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('health', 'Health'),
|
||||||
|
attribute('healing', 'Healing', { unit: '%' }),
|
||||||
|
],
|
||||||
|
protocols: [
|
||||||
|
{
|
||||||
|
id: 'protocol',
|
||||||
|
name: 'Survival',
|
||||||
|
primaryEffect: {
|
||||||
|
stat_attribute_id: 'health',
|
||||||
|
base_value: 100,
|
||||||
|
},
|
||||||
|
secondaryEffects: [{ stat_attribute_id: 'healing', value: 5 }],
|
||||||
|
talent: {
|
||||||
|
name: 'Recovery',
|
||||||
|
description_template: 'Restore {1}.',
|
||||||
|
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
||||||
|
rule: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'health')?.value).toBe(200);
|
||||||
|
expect(stat(summary, 'healing')?.value).toBe(5);
|
||||||
|
expect(summary.equippedTalents[0]).toMatchObject({
|
||||||
|
key: 'protocol:protocol',
|
||||||
|
tier: 5,
|
||||||
|
description: 'Restore 5%.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies static specialization rank effects but excludes combat mechanic outcomes', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.specialization.focusId = 'focus';
|
||||||
|
draft.specialization.nodeRanks.node = 2;
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('static', 'Static'),
|
||||||
|
attribute('mechanic', 'Mechanic'),
|
||||||
|
],
|
||||||
|
specializations: [
|
||||||
|
{
|
||||||
|
id: 'specialization',
|
||||||
|
focuses: [
|
||||||
|
{
|
||||||
|
id: 'focus',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node',
|
||||||
|
name: 'Steady Hand',
|
||||||
|
ranks: [
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
effects: [
|
||||||
|
{
|
||||||
|
effect_type: 'Attribute',
|
||||||
|
stat_attribute_id: 'static',
|
||||||
|
value: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
combatEffects: [
|
||||||
|
{
|
||||||
|
rule: {
|
||||||
|
clauses: [
|
||||||
|
{
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
kind: 'attribute_modifier',
|
||||||
|
attributeId: 'mechanic',
|
||||||
|
value: 99,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stat(summary, 'static')).toEqual({
|
||||||
|
id: 'static',
|
||||||
|
value: 12,
|
||||||
|
sourceCount: 1,
|
||||||
|
});
|
||||||
|
expect(stat(summary, 'mechanic')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a gear Talent tier independently from the gear tier', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.gear.Holster.itemId = 'gear';
|
||||||
|
draft.gear.Holster.talentId = 'talent';
|
||||||
|
Object.assign(draft.gear.Holster, { talentTier: 2 });
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||||
|
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
|
||||||
|
talents: [
|
||||||
|
{
|
||||||
|
id: 'talent',
|
||||||
|
name: 'Reinforced',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'bonus', value: 20 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.equippedTalents).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'talent',
|
||||||
|
tier: 2,
|
||||||
|
tierLabel: 'TIER II',
|
||||||
|
description: 'Gain 20%.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses each weapon Talent tier independently without applying mechanic outcomes to stats', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.weapons[0].itemId = 'weapon';
|
||||||
|
draft.weapons[0].tier = 2;
|
||||||
|
draft.weapons[0].talent1Id = 'talent-one';
|
||||||
|
draft.weapons[0].talent2Id = 'talent-two';
|
||||||
|
Object.assign(draft.weapons[0], {
|
||||||
|
talent1Tier: 1,
|
||||||
|
talent2Tier: 2,
|
||||||
|
exoticTalentTier: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
clauses: [
|
||||||
|
{
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
kind: 'attribute_modifier',
|
||||||
|
attributeId: 'talent-stat',
|
||||||
|
value: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [
|
||||||
|
attribute('talent-stat', 'Talent Stat', { unit: '%' }),
|
||||||
|
attribute('shots', 'Shots', { unit: 'Shots' }),
|
||||||
|
],
|
||||||
|
weapons: [
|
||||||
|
{
|
||||||
|
id: 'weapon',
|
||||||
|
name: 'Test Weapon',
|
||||||
|
exotic_talent_id: 'exotic-talent',
|
||||||
|
baseBonuses: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
talents: [
|
||||||
|
{
|
||||||
|
id: 'talent-one',
|
||||||
|
name: 'Measured',
|
||||||
|
template: 'Gain {1} for {2}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [
|
||||||
|
{ stat_attribute_id: 'talent-stat', value: 10 },
|
||||||
|
{ stat_attribute_id: 'shots', value: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [
|
||||||
|
{ stat_attribute_id: 'talent-stat', value: 20 },
|
||||||
|
{ stat_attribute_id: 'shots', value: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'talent-two',
|
||||||
|
name: 'Calibrated',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 30 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 40 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exotic-talent',
|
||||||
|
name: 'Exotic Talent',
|
||||||
|
template: 'Gain {1}.',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 50 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tier: 'II',
|
||||||
|
effects: [{ stat_attribute_id: 'talent-stat', value: 60 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.equippedTalents).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'talent-one',
|
||||||
|
tier: 1,
|
||||||
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 10% for 2 Shots.',
|
||||||
|
rule,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'talent-two',
|
||||||
|
tier: 2,
|
||||||
|
tierLabel: 'TIER II',
|
||||||
|
description: 'Gain 40%.',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'exotic-talent',
|
||||||
|
tier: 1,
|
||||||
|
tierLabel: 'TIER I',
|
||||||
|
description: 'Gain 50%.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the authored tier description when a Talent has no template row', () => {
|
||||||
|
const draft = createDraft();
|
||||||
|
draft.weapons[0].itemId = 'weapon';
|
||||||
|
draft.weapons[0].talent1Id = 'talent';
|
||||||
|
|
||||||
|
const summary = summarizeLoadout(
|
||||||
|
draft,
|
||||||
|
catalog({
|
||||||
|
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
|
||||||
|
weapons: [
|
||||||
|
{
|
||||||
|
id: 'weapon',
|
||||||
|
name: 'Test Weapon',
|
||||||
|
exotic_talent_id: null,
|
||||||
|
baseBonuses: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
talents: [
|
||||||
|
{
|
||||||
|
id: 'talent',
|
||||||
|
name: 'Measured',
|
||||||
|
template: '',
|
||||||
|
tiers: [
|
||||||
|
{
|
||||||
|
tier: 'I',
|
||||||
|
description: 'Gain {1} while active.',
|
||||||
|
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(summary.equippedTalents[0]?.description).toBe(
|
||||||
|
'Gain 10% while active.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import {
|
||||||
|
gearSlots,
|
||||||
|
scaled,
|
||||||
|
type LoadoutDraft,
|
||||||
|
type TalentTier,
|
||||||
|
} from '$lib/builder/loadout';
|
||||||
|
|
||||||
|
export type LoadoutCatalog = {
|
||||||
|
attributes: any[];
|
||||||
|
gear: any[];
|
||||||
|
gearAugments: any[];
|
||||||
|
brands: any[];
|
||||||
|
weapons: any[];
|
||||||
|
talents: any[];
|
||||||
|
skillChipProfile?: {
|
||||||
|
primaryOptions: any[];
|
||||||
|
secondaryOptions: any[];
|
||||||
|
};
|
||||||
|
chipSets: any[];
|
||||||
|
protocols: any[];
|
||||||
|
specializations: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoadoutSummary = {
|
||||||
|
stats: Array<{ id: string; value: number; sourceCount: number }>;
|
||||||
|
abilityStats: Array<{
|
||||||
|
abilityId: string;
|
||||||
|
stats: Array<{
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
sourceCount: number;
|
||||||
|
sources: Array<{
|
||||||
|
source: string;
|
||||||
|
chipIndex: number;
|
||||||
|
chipSetId: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
activeBonuses: any[];
|
||||||
|
equippedTalents: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||||
|
|
||||||
|
export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) {
|
||||||
|
return catalog.specializations
|
||||||
|
.flatMap((specialization) =>
|
||||||
|
specialization.focuses.map((focus: any) => ({
|
||||||
|
...focus,
|
||||||
|
specialization,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.find((focus: any) => focus.id === draft.specialization.focusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedNodeRanks(
|
||||||
|
draft: LoadoutDraft,
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
) {
|
||||||
|
const focus = selectedFocus(draft, catalog);
|
||||||
|
return (
|
||||||
|
focus?.nodes.flatMap((node: any) => {
|
||||||
|
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
|
||||||
|
return selectedRank
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
node,
|
||||||
|
rank: node.ranks.find(
|
||||||
|
(entry: any) => Number(entry.rank) === selectedRank,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogAttribute(catalog: LoadoutCatalog, id: string) {
|
||||||
|
return catalog.attributes.find((item) => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAttributeValue(
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
current: number,
|
||||||
|
statId: string,
|
||||||
|
incoming: number,
|
||||||
|
) {
|
||||||
|
const stat = catalogAttribute(catalog, statId);
|
||||||
|
const combined =
|
||||||
|
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
|
||||||
|
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
|
||||||
|
: current + incoming;
|
||||||
|
return stat?.cap_value === null || stat?.cap_value === undefined
|
||||||
|
? combined
|
||||||
|
: Math.min(combined, Number(stat.cap_value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
const rendered = Number.isInteger(numeric)
|
||||||
|
? String(numeric)
|
||||||
|
: numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
const unit = catalogAttribute(catalog, statId)?.unit ?? '';
|
||||||
|
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function talentTier(
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
talentId: string,
|
||||||
|
tier: TalentTier,
|
||||||
|
) {
|
||||||
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
|
return item?.tiers.find(
|
||||||
|
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function talentDescription(
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
talentId: string,
|
||||||
|
tier: TalentTier,
|
||||||
|
) {
|
||||||
|
const item = catalog.talents.find((entry) => entry.id === talentId);
|
||||||
|
const configured = talentTier(catalog, talentId, tier);
|
||||||
|
if (!item || !configured) return '';
|
||||||
|
const template = String(item.template || configured.description || item.name);
|
||||||
|
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
|
||||||
|
const effect = configured.effects[Number(raw) - 1];
|
||||||
|
return effect
|
||||||
|
? unitValue(catalog, effect.value, effect.stat_attribute_id)
|
||||||
|
: placeholder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeLoadout(
|
||||||
|
draft: LoadoutDraft,
|
||||||
|
catalog: LoadoutCatalog,
|
||||||
|
): LoadoutSummary {
|
||||||
|
const values = new Map<string, number>();
|
||||||
|
const sources = new Map<string, Set<string>>();
|
||||||
|
const add = (statId: string, value: unknown, source: string) => {
|
||||||
|
if (
|
||||||
|
!statId ||
|
||||||
|
!catalogAttribute(catalog, statId) ||
|
||||||
|
!Number.isFinite(Number(value))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
values.set(
|
||||||
|
statId,
|
||||||
|
applyAttributeValue(
|
||||||
|
catalog,
|
||||||
|
values.get(statId) ?? 0,
|
||||||
|
statId,
|
||||||
|
Number(value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!sources.has(statId)) sources.set(statId, new Set());
|
||||||
|
sources.get(statId)!.add(source);
|
||||||
|
};
|
||||||
|
const byName = (name: string) =>
|
||||||
|
catalog.attributes.find(
|
||||||
|
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
|
||||||
|
)?.id ?? '';
|
||||||
|
const brandCounts = new Map<string, number>();
|
||||||
|
const activeBonuses: any[] = [];
|
||||||
|
const equippedTalents: any[] = [];
|
||||||
|
const abilityValues = new Map<string, Map<string, number>>();
|
||||||
|
const abilitySources = new Map<
|
||||||
|
string,
|
||||||
|
Map<
|
||||||
|
string,
|
||||||
|
Array<{
|
||||||
|
source: string;
|
||||||
|
chipIndex: number;
|
||||||
|
chipSetId: string;
|
||||||
|
value: number;
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
>();
|
||||||
|
const addAbilityStat = (
|
||||||
|
abilityId: string,
|
||||||
|
statId: string,
|
||||||
|
value: unknown,
|
||||||
|
chipIndex: number,
|
||||||
|
chipSetId: string,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!abilityId ||
|
||||||
|
!statId ||
|
||||||
|
!catalogAttribute(catalog, statId) ||
|
||||||
|
!Number.isFinite(Number(value))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
|
||||||
|
const valuesForAbility = abilityValues.get(abilityId)!;
|
||||||
|
valuesForAbility.set(
|
||||||
|
statId,
|
||||||
|
applyAttributeValue(
|
||||||
|
catalog,
|
||||||
|
valuesForAbility.get(statId) ?? 0,
|
||||||
|
statId,
|
||||||
|
Number(value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!abilitySources.has(abilityId))
|
||||||
|
abilitySources.set(abilityId, new Map());
|
||||||
|
const sourcesForAbility = abilitySources.get(abilityId)!;
|
||||||
|
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
|
||||||
|
sourcesForAbility.get(statId)!.push({
|
||||||
|
source: `Skill Chip ${chipIndex + 1} primary`,
|
||||||
|
chipIndex,
|
||||||
|
chipSetId,
|
||||||
|
value: Number(value),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const slot of gearSlots) {
|
||||||
|
const selection = draft.gear[slot];
|
||||||
|
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||||
|
if (!item) continue;
|
||||||
|
const enabledAugments = catalog.gearAugments.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.gear_slot === slot &&
|
||||||
|
selection.augmentSlots.includes(Number(entry.augment_slot)),
|
||||||
|
);
|
||||||
|
const basePercentage = enabledAugments
|
||||||
|
.filter((entry) => entry.effect_type === 'Base Percentage')
|
||||||
|
.reduce((total, entry) => total + Number(entry.value), 0);
|
||||||
|
add(
|
||||||
|
byName('Health'),
|
||||||
|
selection.health * (1 + basePercentage / 100),
|
||||||
|
`${slot} base`,
|
||||||
|
);
|
||||||
|
if (item.base_armor_min !== null)
|
||||||
|
add(
|
||||||
|
byName('Armor'),
|
||||||
|
selection.armor * (1 + basePercentage / 100),
|
||||||
|
`${slot} base`,
|
||||||
|
);
|
||||||
|
for (const roll of selection.rolls)
|
||||||
|
add(roll.statId, roll.value, `${slot} roll`);
|
||||||
|
for (const key of coreKeys)
|
||||||
|
add(byName(key), selection[key], `${slot} core`);
|
||||||
|
if (selection.brandId)
|
||||||
|
brandCounts.set(
|
||||||
|
selection.brandId,
|
||||||
|
(brandCounts.get(selection.brandId) ?? 0) + 1,
|
||||||
|
);
|
||||||
|
for (const augment of enabledAugments.filter(
|
||||||
|
(entry) => entry.effect_type === 'Custom',
|
||||||
|
)) {
|
||||||
|
add(
|
||||||
|
augment.stat_attribute_id,
|
||||||
|
augment.value,
|
||||||
|
`${slot} augment ${augment.augment_slot}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selection.talentId) {
|
||||||
|
equippedTalents.push({
|
||||||
|
key: `gear:${slot}:${selection.talentId}`,
|
||||||
|
id: selection.talentId,
|
||||||
|
rule:
|
||||||
|
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||||
|
?.rule ?? null,
|
||||||
|
tier: selection.talentTier,
|
||||||
|
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: false,
|
||||||
|
source: slot,
|
||||||
|
description: talentDescription(
|
||||||
|
catalog,
|
||||||
|
selection.talentId,
|
||||||
|
selection.talentTier,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [brandId, count] of brandCounts) {
|
||||||
|
const set = catalog.brands.find((entry) => entry.id === brandId);
|
||||||
|
for (const effect of set?.effects.filter(
|
||||||
|
(entry: any) => Number(entry.pieces_required) <= count,
|
||||||
|
) ?? []) {
|
||||||
|
add(
|
||||||
|
effect.stat_attribute_id,
|
||||||
|
effect.value,
|
||||||
|
`${set.name} ${effect.pieces_required}-piece`,
|
||||||
|
);
|
||||||
|
activeBonuses.push({
|
||||||
|
name: set.name,
|
||||||
|
pieces: effect.pieces_required,
|
||||||
|
count,
|
||||||
|
statId: effect.stat_attribute_id,
|
||||||
|
value: effect.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
const selection = draft.weapons[index];
|
||||||
|
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||||
|
if (!item) continue;
|
||||||
|
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
|
||||||
|
add(
|
||||||
|
byName(name),
|
||||||
|
selection[name.toLowerCase() as 'firepower'],
|
||||||
|
`${index ? 'Secondary' : 'Primary'} weapon`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const bonus of item.baseBonuses ?? [])
|
||||||
|
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
|
||||||
|
for (const roll of selection.rolls)
|
||||||
|
add(
|
||||||
|
roll.statId,
|
||||||
|
roll.value,
|
||||||
|
`${index ? 'Secondary' : 'Primary'} weapon roll`,
|
||||||
|
);
|
||||||
|
const equippedWeaponTalents = [
|
||||||
|
{
|
||||||
|
id: selection.talent1Id,
|
||||||
|
tier: selection.talent1Tier,
|
||||||
|
isExotic: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: selection.talent2Id,
|
||||||
|
tier: selection.talent2Tier,
|
||||||
|
isExotic: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: item.exotic_talent_id,
|
||||||
|
tier: selection.exoticTalentTier,
|
||||||
|
isExotic: true,
|
||||||
|
},
|
||||||
|
].filter((entry) => entry.id);
|
||||||
|
for (const equippedTalent of equippedWeaponTalents) {
|
||||||
|
equippedTalents.push({
|
||||||
|
key: `weapon:${index}:${equippedTalent.id}`,
|
||||||
|
id: equippedTalent.id,
|
||||||
|
rule:
|
||||||
|
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||||
|
?.rule ?? null,
|
||||||
|
tier: equippedTalent.tier,
|
||||||
|
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||||
|
isExotic: equippedTalent.isExotic,
|
||||||
|
source: index ? 'Secondary weapon' : 'Primary weapon',
|
||||||
|
description: talentDescription(
|
||||||
|
catalog,
|
||||||
|
equippedTalent.id,
|
||||||
|
equippedTalent.tier,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipGroups = new Map<
|
||||||
|
string,
|
||||||
|
{ count: number; highEnd: number; item: any; abilityId: string }
|
||||||
|
>();
|
||||||
|
const focus = selectedFocus(draft, catalog);
|
||||||
|
const validChipRoll = (
|
||||||
|
kind: 'primaryOptions' | 'secondaryOptions',
|
||||||
|
quality: string,
|
||||||
|
roll: { statId: string; value: number },
|
||||||
|
) => {
|
||||||
|
if (!catalog.skillChipProfile) return true;
|
||||||
|
const range = catalog.skillChipProfile[kind].find(
|
||||||
|
(option) => option.statId === roll.statId,
|
||||||
|
)?.ranges?.[quality];
|
||||||
|
return (
|
||||||
|
range &&
|
||||||
|
roll.value >= Number(range.min) &&
|
||||||
|
roll.value <= Number(range.max)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (let index = 0; index < 9; index += 1) {
|
||||||
|
const selection = draft.chips[index];
|
||||||
|
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
|
||||||
|
if (!item) continue;
|
||||||
|
const slotAbilityId = String(
|
||||||
|
focus?.abilities?.[Math.floor(index / 3)]?.id ?? '',
|
||||||
|
);
|
||||||
|
const compatibleAbilityIds = item.compatibleAbilityIds ?? [
|
||||||
|
String(item.specialization_ability_id ?? ''),
|
||||||
|
];
|
||||||
|
if (slotAbilityId && !compatibleAbilityIds.includes(slotAbilityId))
|
||||||
|
continue;
|
||||||
|
const abilityId =
|
||||||
|
slotAbilityId || String(item.specialization_ability_id ?? '');
|
||||||
|
if (validChipRoll('primaryOptions', selection.quality, selection.primary))
|
||||||
|
addAbilityStat(
|
||||||
|
abilityId,
|
||||||
|
selection.primary.statId,
|
||||||
|
selection.primary.value,
|
||||||
|
index,
|
||||||
|
String(item.id),
|
||||||
|
);
|
||||||
|
for (const roll of selection.secondary)
|
||||||
|
if (validChipRoll('secondaryOptions', selection.quality, roll))
|
||||||
|
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
|
||||||
|
const groupKey = `${item.id}:${abilityId}`;
|
||||||
|
const group = chipGroups.get(groupKey) ?? {
|
||||||
|
count: 0,
|
||||||
|
highEnd: 0,
|
||||||
|
item,
|
||||||
|
abilityId,
|
||||||
|
};
|
||||||
|
group.count += 1;
|
||||||
|
if (selection.quality === 'High-end') group.highEnd += 1;
|
||||||
|
chipGroups.set(groupKey, group);
|
||||||
|
}
|
||||||
|
for (const group of chipGroups.values()) {
|
||||||
|
if (group.count >= 2 && group.item.twoPiece) {
|
||||||
|
const value = scaled(
|
||||||
|
group.item.twoPiece.min_value,
|
||||||
|
group.item.twoPiece.max_value,
|
||||||
|
group.highEnd,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
group.item.twoPiece.stat_attribute_id,
|
||||||
|
value,
|
||||||
|
`${group.item.name} 2-piece`,
|
||||||
|
);
|
||||||
|
activeBonuses.push({
|
||||||
|
name: group.item.name,
|
||||||
|
pieces: 2,
|
||||||
|
count: group.count,
|
||||||
|
statId: group.item.twoPiece.stat_attribute_id,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (group.count === 3 && group.item.talent) {
|
||||||
|
equippedTalents.push({
|
||||||
|
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||||
|
id: '',
|
||||||
|
custom: group.item.talent,
|
||||||
|
rule: group.item.talent.rule ?? null,
|
||||||
|
tier: group.highEnd,
|
||||||
|
abilityId: group.abilityId || null,
|
||||||
|
source: '3 Skill Chips',
|
||||||
|
description: group.item.talent.description_template.replace(
|
||||||
|
/\{(\d+)\}/g,
|
||||||
|
(placeholder: string, raw: string) => {
|
||||||
|
const effect = group.item.talent.effects[Number(raw) - 1];
|
||||||
|
return effect
|
||||||
|
? unitValue(
|
||||||
|
catalog,
|
||||||
|
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
|
||||||
|
effect.stat_attribute_id,
|
||||||
|
)
|
||||||
|
: placeholder;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
|
||||||
|
for (const effect of rank?.effects ?? []) {
|
||||||
|
if (effect.effect_type === 'Attribute')
|
||||||
|
add(
|
||||||
|
effect.stat_attribute_id,
|
||||||
|
effect.value,
|
||||||
|
`${node.name} rank ${rank.rank}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = catalog.protocols.find(
|
||||||
|
(entry) => entry.id === draft.protocol.itemId,
|
||||||
|
);
|
||||||
|
if (protocol) {
|
||||||
|
if (protocol.primaryEffect) {
|
||||||
|
add(
|
||||||
|
protocol.primaryEffect.stat_attribute_id,
|
||||||
|
Number(protocol.primaryEffect.base_value) *
|
||||||
|
(1 + (draft.protocol.level - 1) / 49),
|
||||||
|
`${protocol.name} primary`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const effect of protocol.secondaryEffects ?? []) {
|
||||||
|
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
|
||||||
|
}
|
||||||
|
if (protocol.talent) {
|
||||||
|
equippedTalents.push({
|
||||||
|
key: `protocol:${protocol.id}`,
|
||||||
|
id: '',
|
||||||
|
custom: protocol.talent,
|
||||||
|
rule: protocol.talent.rule ?? null,
|
||||||
|
tier: draft.protocol.talentUpgrades,
|
||||||
|
source: 'OS Protocol',
|
||||||
|
description: protocol.talent.description_template.replace(
|
||||||
|
/\{(\d+)\}/g,
|
||||||
|
(placeholder: string, raw: string) => {
|
||||||
|
const effect = protocol.talent.effects[Number(raw) - 1];
|
||||||
|
return effect
|
||||||
|
? unitValue(
|
||||||
|
catalog,
|
||||||
|
Number(effect.base_value) *
|
||||||
|
(1 + draft.protocol.talentUpgrades / 5),
|
||||||
|
effect.stat_attribute_id,
|
||||||
|
)
|
||||||
|
: placeholder;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: [...values]
|
||||||
|
.map(([id, value]) => ({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
sourceCount: sources.get(id)?.size ?? 0,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||||
|
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||||
|
) ||
|
||||||
|
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||||
|
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
|
||||||
|
abilityId,
|
||||||
|
stats: [...abilityStatValues]
|
||||||
|
.map(([id, value]) => {
|
||||||
|
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
sourceCount: statSources.length,
|
||||||
|
sources: statSources,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
|
||||||
|
catalogAttribute(catalog, right.id)?.category ?? '',
|
||||||
|
) ||
|
||||||
|
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
|
||||||
|
catalogAttribute(catalog, right.id)?.name ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
activeBonuses,
|
||||||
|
equippedTalents,
|
||||||
|
};
|
||||||
|
}
|
||||||
+243
-58
@@ -1,79 +1,264 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createDraft, parseLoadoutDraft } from './loadout.js';
|
import {
|
||||||
|
createDraft,
|
||||||
|
migrateLoadoutDraftV5,
|
||||||
|
parseLoadoutDraft,
|
||||||
|
skillChipSecondaryCapacity,
|
||||||
|
type SkillChipQuality,
|
||||||
|
} from './loadout.js';
|
||||||
|
|
||||||
const copyDraft = () => structuredClone(createDraft());
|
const copyDraft = () => structuredClone(createDraft());
|
||||||
|
|
||||||
|
const talentTierFields = [
|
||||||
|
[
|
||||||
|
'gear talentTier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.gear.Holster as unknown as Record<string, unknown>,
|
||||||
|
'talentTier',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'weapon talent1Tier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||||
|
'talent1Tier',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'weapon talent2Tier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||||
|
'talent2Tier',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'weapon exoticTalentTier',
|
||||||
|
(draft: ReturnType<typeof copyDraft>) =>
|
||||||
|
draft.weapons[0] as unknown as Record<string, unknown>,
|
||||||
|
'exoticTalentTier',
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
describe('parseLoadoutDraft', () => {
|
describe('parseLoadoutDraft', () => {
|
||||||
it('accepts a current draft and returns an independent clone', () => {
|
it('accepts a current draft and returns an independent clone', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
const parsed = parseLoadoutDraft(draft);
|
const parsed = parseLoadoutDraft(draft);
|
||||||
|
|
||||||
expect(parsed).toEqual(draft);
|
expect(parsed).toEqual(draft);
|
||||||
expect(parsed?.calculationMode).toBe('maximum');
|
expect(parsed?.version).toBe(6);
|
||||||
expect(parsed).not.toBe(draft);
|
expect(parsed?.chips[0]).toEqual({
|
||||||
parsed!.gear.Holster.level = 1;
|
setId: '',
|
||||||
expect(draft.gear.Holster.level).toBe(50);
|
quality: 'Standard',
|
||||||
});
|
primary: { statId: '', value: 0 },
|
||||||
|
secondary: [],
|
||||||
|
});
|
||||||
|
expect(parsed?.calculationMode).toBe('maximum');
|
||||||
|
expect(parsed).not.toBe(draft);
|
||||||
|
parsed!.gear.Holster.level = 1;
|
||||||
|
expect(draft.gear.Holster.level).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects the retired version 1 three-chip shape', () => {
|
it('defaults every equipment Talent tier to Tier I', () => {
|
||||||
const retired = copyDraft() as unknown as Record<string, unknown>;
|
const draft = createDraft();
|
||||||
retired.version = 1;
|
|
||||||
retired.chips = (retired.chips as unknown[]).slice(0, 3);
|
|
||||||
|
|
||||||
expect(parseLoadoutDraft(retired)).toBeNull();
|
expect(
|
||||||
});
|
Object.values(draft.gear).map(
|
||||||
|
(selection) =>
|
||||||
|
(selection as unknown as Record<string, unknown>).talentTier,
|
||||||
|
),
|
||||||
|
).toEqual(Array(6).fill(1));
|
||||||
|
expect(
|
||||||
|
draft.weapons.map((selection) => {
|
||||||
|
const weapon = selection as unknown as Record<string, unknown>;
|
||||||
|
return [
|
||||||
|
weapon.talent1Tier,
|
||||||
|
weapon.talent2Tier,
|
||||||
|
weapon.exoticTalentTier,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
[1, 1, 1],
|
||||||
|
[1, 1, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
|
it('accepts Tier II for every equipment Talent selection', () => {
|
||||||
const draft = copyDraft();
|
const draft = copyDraft();
|
||||||
draft.chips.pop();
|
for (const [, select, key] of talentTierFields) select(draft)[key] = 2;
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a draft missing the unified combat profile', () => {
|
it('rejects the retired version 1 three-chip shape', () => {
|
||||||
const draft = copyDraft() as unknown as Record<string, unknown>;
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
delete draft.combatProfile;
|
retired.version = 1;
|
||||||
|
retired.chips = (retired.chips as unknown[]).slice(0, 3);
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a custom scenario and preserves its assumptions', () => {
|
it('rejects the retired version 4 schema', () => {
|
||||||
const draft = copyDraft();
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
draft.calculationMode = 'custom';
|
retired.version = 4;
|
||||||
draft.combatProfile.hitRate = 63;
|
|
||||||
draft.combatProfile.targetDistanceMeters = 28;
|
|
||||||
draft.conditionalEffects.relentless = {
|
|
||||||
mode: 'encounter',
|
|
||||||
inputs: {
|
|
||||||
alliesWithinRange: 3,
|
|
||||||
targetHasDebuff: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects drafts without the required calculation model', () => {
|
it('rejects the retired version 5 schema', () => {
|
||||||
const missing = copyDraft() as unknown as Record<string, unknown>;
|
const retired = copyDraft() as unknown as Record<string, unknown>;
|
||||||
delete missing.calculationMode;
|
retired.version = 5;
|
||||||
expect(parseLoadoutDraft(missing)).toBeNull();
|
|
||||||
|
|
||||||
const invalid = copyDraft() as unknown as Record<string, unknown>;
|
expect(parseLoadoutDraft(retired)).toBeNull();
|
||||||
invalid.calculationMode = 'legacy';
|
});
|
||||||
expect(parseLoadoutDraft(invalid)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a specialization that carries retired unassigned chips', () => {
|
it('migrates version 5 equipment-derived Talent tiers once', () => {
|
||||||
const draft = copyDraft() as unknown as { specialization: Record<string, unknown> };
|
const legacy = copyDraft() as unknown as Record<string, any>;
|
||||||
draft.specialization.unassignedChips = [];
|
legacy.version = 5;
|
||||||
|
for (const selection of Object.values(legacy.gear) as Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>[]) {
|
||||||
|
delete selection.talentTier;
|
||||||
|
}
|
||||||
|
for (const selection of legacy.weapons as Record<string, any>[]) {
|
||||||
|
delete selection.talent1Tier;
|
||||||
|
delete selection.talent2Tier;
|
||||||
|
delete selection.exoticTalentTier;
|
||||||
|
}
|
||||||
|
legacy.gear.Holster.itemId = 'tier-two-gear';
|
||||||
|
legacy.weapons[0].itemId = 'exotic-weapon';
|
||||||
|
legacy.weapons[0].tier = 2;
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
const migrated = migrateLoadoutDraftV5(legacy, {
|
||||||
});
|
gear: [{ id: 'tier-two-gear', tier: 2 }],
|
||||||
|
weapons: [{ id: 'exotic-weapon', exotic_talent_id: 'exotic-talent' }],
|
||||||
|
talents: [
|
||||||
|
{
|
||||||
|
id: 'exotic-talent',
|
||||||
|
tiers: [{ tier: 'I' }, { tier: 'II' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects malformed nested equipment selections', () => {
|
expect(migrated?.version).toBe(6);
|
||||||
const draft = copyDraft();
|
expect(migrated?.gear.Holster.talentTier).toBe(2);
|
||||||
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
|
expect(migrated?.gear.Gloves.talentTier).toBe(1);
|
||||||
|
expect(migrated?.weapons[0]).toMatchObject({
|
||||||
|
talent1Tier: 2,
|
||||||
|
talent2Tier: 2,
|
||||||
|
exoticTalentTier: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(parseLoadoutDraft(draft)).toBeNull();
|
it('rejects a current draft that does not contain exactly nine chips', () => {
|
||||||
});
|
const draft = copyDraft();
|
||||||
|
draft.chips.pop();
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(talentTierFields)('rejects a draft missing %s', (_, select, key) => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
delete select(draft)[key];
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
talentTierFields.flatMap(([name, select, key]) =>
|
||||||
|
[0, 1.5, 3].map((value) => [name, value, select, key] as const),
|
||||||
|
),
|
||||||
|
)('rejects %s value %s', (_, value, select, key) => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
select(draft)[key] = value;
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Standard', 0],
|
||||||
|
['Specialized', 1],
|
||||||
|
['Superior', 2],
|
||||||
|
['High-end', 2],
|
||||||
|
] as const)(
|
||||||
|
'accepts %s chips with exactly %i secondary rolls',
|
||||||
|
(quality, capacity) => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
draft.chips[0].quality = quality;
|
||||||
|
draft.chips[0].primary = { statId: 'primary', value: 10 };
|
||||||
|
draft.chips[0].secondary = Array.from(
|
||||||
|
{ length: capacity },
|
||||||
|
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
|
||||||
|
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Standard', 1],
|
||||||
|
['Specialized', 0],
|
||||||
|
['Specialized', 2],
|
||||||
|
['Superior', 1],
|
||||||
|
['High-end', 3],
|
||||||
|
] as Array<[SkillChipQuality, number]>)(
|
||||||
|
'rejects %s chips with %i secondary rolls',
|
||||||
|
(quality, capacity) => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
draft.chips[0].quality = quality;
|
||||||
|
draft.chips[0].secondary = Array.from(
|
||||||
|
{ length: capacity },
|
||||||
|
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects a draft missing the unified combat profile', () => {
|
||||||
|
const draft = copyDraft() as unknown as Record<string, unknown>;
|
||||||
|
delete draft.combatProfile;
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom scenario and preserves its assumptions', () => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
draft.calculationMode = 'custom';
|
||||||
|
draft.combatProfile.hitRate = 63;
|
||||||
|
draft.combatProfile.targetDistanceMeters = 28;
|
||||||
|
draft.conditionalEffects.relentless = {
|
||||||
|
mode: 'encounter',
|
||||||
|
inputs: {
|
||||||
|
alliesWithinRange: 3,
|
||||||
|
targetHasDebuff: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toEqual(draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects drafts without the required calculation model', () => {
|
||||||
|
const missing = copyDraft() as unknown as Record<string, unknown>;
|
||||||
|
delete missing.calculationMode;
|
||||||
|
expect(parseLoadoutDraft(missing)).toBeNull();
|
||||||
|
|
||||||
|
const invalid = copyDraft() as unknown as Record<string, unknown>;
|
||||||
|
invalid.calculationMode = 'legacy';
|
||||||
|
expect(parseLoadoutDraft(invalid)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a specialization that carries retired unassigned chips', () => {
|
||||||
|
const draft = copyDraft() as unknown as {
|
||||||
|
specialization: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
draft.specialization.unassignedChips = [];
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed nested equipment selections', () => {
|
||||||
|
const draft = copyDraft();
|
||||||
|
draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN };
|
||||||
|
|
||||||
|
expect(parseLoadoutDraft(draft)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+530
-227
@@ -1,279 +1,582 @@
|
|||||||
export type Roll = { statId: string; value: number };
|
export type Roll = { statId: string; value: number };
|
||||||
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; firepower: number; toughness: number; engineering: number; rolls: [Roll, Roll]; augmentSlots: number[] };
|
export type TalentTier = 1 | 2;
|
||||||
export type WeaponSelection = { itemId: string; level: number; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string; rolls: [Roll, Roll] };
|
export const skillChipQualities = [
|
||||||
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
|
'Standard',
|
||||||
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
|
'Specialized',
|
||||||
|
'Superior',
|
||||||
|
'High-end',
|
||||||
|
] as const;
|
||||||
|
export type SkillChipQuality = (typeof skillChipQualities)[number];
|
||||||
|
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
|
||||||
|
Standard: 0,
|
||||||
|
Specialized: 1,
|
||||||
|
Superior: 2,
|
||||||
|
'High-end': 2,
|
||||||
|
};
|
||||||
|
export type GearSelection = {
|
||||||
|
itemId: string;
|
||||||
|
level: number;
|
||||||
|
brandId: string;
|
||||||
|
talentId: string;
|
||||||
|
talentTier: TalentTier;
|
||||||
|
health: number;
|
||||||
|
armor: number;
|
||||||
|
firepower: number;
|
||||||
|
toughness: number;
|
||||||
|
engineering: number;
|
||||||
|
rolls: [Roll, Roll];
|
||||||
|
augmentSlots: number[];
|
||||||
|
};
|
||||||
|
export type WeaponSelection = {
|
||||||
|
itemId: string;
|
||||||
|
level: number;
|
||||||
|
tier: number;
|
||||||
|
damage: number;
|
||||||
|
firepower: number;
|
||||||
|
toughness: number;
|
||||||
|
engineering: number;
|
||||||
|
talent1Id: string;
|
||||||
|
talent1Tier: TalentTier;
|
||||||
|
talent2Id: string;
|
||||||
|
talent2Tier: TalentTier;
|
||||||
|
exoticTalentTier: TalentTier;
|
||||||
|
rolls: [Roll, Roll];
|
||||||
|
};
|
||||||
|
export type ChipSelection = {
|
||||||
|
setId: string;
|
||||||
|
quality: SkillChipQuality;
|
||||||
|
primary: Roll;
|
||||||
|
secondary: Roll[];
|
||||||
|
};
|
||||||
|
export type ProtocolSelection = {
|
||||||
|
itemId: string;
|
||||||
|
level: number;
|
||||||
|
talentUpgrades: number;
|
||||||
|
};
|
||||||
export type CombatProfile = {
|
export type CombatProfile = {
|
||||||
encounterSeconds: number;
|
encounterSeconds: number;
|
||||||
hitRate: number;
|
hitRate: number;
|
||||||
headshotRate: number;
|
headshotRate: number;
|
||||||
shotsPerBurst: number;
|
shotsPerBurst: number;
|
||||||
burstPauseSeconds: number;
|
burstPauseSeconds: number;
|
||||||
sameTargetSeconds: number;
|
sameTargetSeconds: number;
|
||||||
distinctTargetsPerMinute: number;
|
distinctTargetsPerMinute: number;
|
||||||
killsPerMinute: number;
|
killsPerMinute: number;
|
||||||
headshotKillRate: number;
|
headshotKillRate: number;
|
||||||
damageTakenPerMinute: number;
|
damageTakenPerMinute: number;
|
||||||
swapsPerMinute: number;
|
swapsPerMinute: number;
|
||||||
targetDistanceMeters: number;
|
targetDistanceMeters: number;
|
||||||
healthPercent: number;
|
healthPercent: number;
|
||||||
extraHealthActive: boolean;
|
extraHealthActive: boolean;
|
||||||
};
|
};
|
||||||
export type CalculationMode = 'maximum' | 'custom';
|
export type CalculationMode = 'maximum' | 'custom';
|
||||||
export type EffectMode = 'off' | 'peak' | 'encounter';
|
export type EffectMode = 'off' | 'peak' | 'encounter';
|
||||||
export type ConditionalEffectSelection = {
|
export type ConditionalEffectSelection = {
|
||||||
mode: EffectMode;
|
mode: EffectMode;
|
||||||
overrides?: Partial<CombatProfile>;
|
overrides?: Partial<CombatProfile>;
|
||||||
inputs?: Record<string, number | boolean>;
|
inputs?: Record<string, number | boolean>;
|
||||||
};
|
};
|
||||||
export type SkillActivationSelection = {
|
export type SkillActivationSelection = {
|
||||||
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
mode: 'off' | 'on_cooldown' | 'custom_per_minute';
|
||||||
usesPerMinute?: number;
|
usesPerMinute?: number;
|
||||||
targets: number;
|
targets: number;
|
||||||
};
|
};
|
||||||
export type SpecializationSelection = {
|
export type SpecializationSelection = {
|
||||||
focusId: string;
|
focusId: string;
|
||||||
nodeRanks: Record<string, number>;
|
nodeRanks: Record<string, number>;
|
||||||
};
|
};
|
||||||
export type LoadoutDraft = {
|
export type LoadoutDraft = {
|
||||||
version: 4;
|
version: 6;
|
||||||
buildName: string;
|
buildName: string;
|
||||||
calculationMode: CalculationMode;
|
calculationMode: CalculationMode;
|
||||||
gear: Record<string, GearSelection>;
|
gear: Record<string, GearSelection>;
|
||||||
weapons: [WeaponSelection, WeaponSelection];
|
weapons: [WeaponSelection, WeaponSelection];
|
||||||
chips: [ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection, ChipSelection];
|
chips: [
|
||||||
protocol: ProtocolSelection;
|
ChipSelection,
|
||||||
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
ChipSelection,
|
||||||
combatProfile: CombatProfile;
|
ChipSelection,
|
||||||
skillActivations: Record<string, SkillActivationSelection>;
|
ChipSelection,
|
||||||
specialization: SpecializationSelection;
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
ChipSelection,
|
||||||
|
];
|
||||||
|
protocol: ProtocolSelection;
|
||||||
|
conditionalEffects: Record<string, ConditionalEffectSelection>;
|
||||||
|
combatProfile: CombatProfile;
|
||||||
|
skillActivations: Record<string, SkillActivationSelection>;
|
||||||
|
specialization: SpecializationSelection;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
|
export const gearSlots = [
|
||||||
|
'Holster',
|
||||||
|
'Gloves',
|
||||||
|
'Backpack',
|
||||||
|
'Knee Pads',
|
||||||
|
'Body Armor',
|
||||||
|
'Mask',
|
||||||
|
] as const;
|
||||||
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
|
||||||
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
|
export const blankGear = (): GearSelection => ({
|
||||||
export const blankWeapon = (): WeaponSelection => ({ itemId: '', level: 50, tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '', rolls: [blankRoll(), blankRoll()] });
|
itemId: '',
|
||||||
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
|
level: 50,
|
||||||
|
brandId: '',
|
||||||
|
talentId: '',
|
||||||
|
talentTier: 1,
|
||||||
|
health: 0,
|
||||||
|
armor: 0,
|
||||||
|
firepower: 0,
|
||||||
|
toughness: 0,
|
||||||
|
engineering: 0,
|
||||||
|
rolls: [blankRoll(), blankRoll()],
|
||||||
|
augmentSlots: [],
|
||||||
|
});
|
||||||
|
export const blankWeapon = (): WeaponSelection => ({
|
||||||
|
itemId: '',
|
||||||
|
level: 50,
|
||||||
|
tier: 0,
|
||||||
|
damage: 0,
|
||||||
|
firepower: 0,
|
||||||
|
toughness: 0,
|
||||||
|
engineering: 0,
|
||||||
|
talent1Id: '',
|
||||||
|
talent1Tier: 1,
|
||||||
|
talent2Id: '',
|
||||||
|
talent2Tier: 1,
|
||||||
|
exoticTalentTier: 1,
|
||||||
|
rolls: [blankRoll(), blankRoll()],
|
||||||
|
});
|
||||||
|
export const blankChip = (): ChipSelection => ({
|
||||||
|
setId: '',
|
||||||
|
quality: 'Standard',
|
||||||
|
primary: blankRoll(),
|
||||||
|
secondary: [],
|
||||||
|
});
|
||||||
export const defaultCombatProfile = (): CombatProfile => ({
|
export const defaultCombatProfile = (): CombatProfile => ({
|
||||||
encounterSeconds: 60,
|
encounterSeconds: 60,
|
||||||
hitRate: 75,
|
hitRate: 75,
|
||||||
headshotRate: 20,
|
headshotRate: 20,
|
||||||
shotsPerBurst: 30,
|
shotsPerBurst: 30,
|
||||||
burstPauseSeconds: 0,
|
burstPauseSeconds: 0,
|
||||||
sameTargetSeconds: 5,
|
sameTargetSeconds: 5,
|
||||||
distinctTargetsPerMinute: 12,
|
distinctTargetsPerMinute: 12,
|
||||||
killsPerMinute: 4,
|
killsPerMinute: 4,
|
||||||
headshotKillRate: 20,
|
headshotKillRate: 20,
|
||||||
damageTakenPerMinute: 6,
|
damageTakenPerMinute: 6,
|
||||||
swapsPerMinute: 2,
|
swapsPerMinute: 2,
|
||||||
targetDistanceMeters: 15,
|
targetDistanceMeters: 15,
|
||||||
healthPercent: 100,
|
healthPercent: 100,
|
||||||
extraHealthActive: false
|
extraHealthActive: false,
|
||||||
});
|
});
|
||||||
export const createDraft = (): LoadoutDraft => ({
|
export const createDraft = (): LoadoutDraft => ({
|
||||||
version: 4,
|
version: 6,
|
||||||
buildName: 'Untitled loadout',
|
buildName: 'Untitled loadout',
|
||||||
calculationMode: 'maximum',
|
calculationMode: 'maximum',
|
||||||
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
|
||||||
weapons: [blankWeapon(), blankWeapon()],
|
weapons: [blankWeapon(), blankWeapon()],
|
||||||
chips: [blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip(), blankChip()],
|
chips: [
|
||||||
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
blankChip(),
|
||||||
conditionalEffects: {},
|
blankChip(),
|
||||||
combatProfile: defaultCombatProfile(),
|
blankChip(),
|
||||||
skillActivations: {},
|
blankChip(),
|
||||||
specialization: {
|
blankChip(),
|
||||||
focusId: '',
|
blankChip(),
|
||||||
nodeRanks: {}
|
blankChip(),
|
||||||
}
|
blankChip(),
|
||||||
|
blankChip(),
|
||||||
|
],
|
||||||
|
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
|
||||||
|
conditionalEffects: {},
|
||||||
|
combatProfile: defaultCombatProfile(),
|
||||||
|
skillActivations: {},
|
||||||
|
specialization: {
|
||||||
|
focusId: '',
|
||||||
|
nodeRanks: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function migrateLoadoutDraftV5(
|
||||||
|
value: unknown,
|
||||||
|
catalog: { gear: any[]; weapons: any[]; talents: any[] },
|
||||||
|
): LoadoutDraft | null {
|
||||||
|
if (!isRecord(value) || value.version !== 5) return null;
|
||||||
|
const migrated = structuredClone(value);
|
||||||
|
if (!isRecord(migrated.gear) || !Array.isArray(migrated.weapons)) return null;
|
||||||
|
|
||||||
|
for (const slot of gearSlots) {
|
||||||
|
const selection = migrated.gear[slot];
|
||||||
|
if (!isRecord(selection)) return null;
|
||||||
|
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
|
||||||
|
selection.talentTier = Number(item?.tier) >= 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
for (const selection of migrated.weapons) {
|
||||||
|
if (!isRecord(selection)) return null;
|
||||||
|
const inheritedTier: TalentTier = Number(selection.tier) >= 2 ? 2 : 1;
|
||||||
|
selection.talent1Tier = inheritedTier;
|
||||||
|
selection.talent2Tier = inheritedTier;
|
||||||
|
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
|
||||||
|
const exoticTalent = catalog.talents.find(
|
||||||
|
(entry) => entry.id === item?.exotic_talent_id,
|
||||||
|
);
|
||||||
|
selection.exoticTalentTier =
|
||||||
|
inheritedTier === 2 &&
|
||||||
|
exoticTalent?.tiers?.some((entry: any) => entry.tier === 'II')
|
||||||
|
? 2
|
||||||
|
: 1;
|
||||||
|
}
|
||||||
|
migrated.version = 6;
|
||||||
|
return parseLoadoutDraft(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
|
||||||
Object.keys(value).length === keys.length && keys.every((key) => key in value);
|
Object.keys(value).length === keys.length &&
|
||||||
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
|
keys.every((key) => key in value);
|
||||||
|
const isFiniteNumber = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value);
|
||||||
|
const isTalentTier = (value: unknown): value is TalentTier =>
|
||||||
|
value === 1 || value === 2;
|
||||||
const isNumberRecord = (value: unknown) =>
|
const isNumberRecord = (value: unknown) =>
|
||||||
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry));
|
isRecord(value) &&
|
||||||
|
Object.values(value).every(
|
||||||
|
(entry) => typeof entry === 'number' && Number.isFinite(entry),
|
||||||
|
);
|
||||||
const isRoll = (value: unknown): value is Roll =>
|
const isRoll = (value: unknown): value is Roll =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['statId', 'value'])
|
hasExactKeys(value, ['statId', 'value']) &&
|
||||||
&& typeof value.statId === 'string'
|
typeof value.statId === 'string' &&
|
||||||
&& isFiniteNumber(value.value);
|
isFiniteNumber(value.value);
|
||||||
const isRollPair = (value: unknown): value is [Roll, Roll] =>
|
const isRollPair = (value: unknown): value is [Roll, Roll] =>
|
||||||
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
Array.isArray(value) && value.length === 2 && value.every(isRoll);
|
||||||
const isGearSelection = (value: unknown): value is GearSelection =>
|
const isGearSelection = (value: unknown): value is GearSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
|
hasExactKeys(value, [
|
||||||
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
|
'itemId',
|
||||||
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
|
'level',
|
||||||
&& isRollPair(value.rolls)
|
'brandId',
|
||||||
&& Array.isArray(value.augmentSlots)
|
'talentId',
|
||||||
&& value.augmentSlots.every(isFiniteNumber);
|
'talentTier',
|
||||||
|
'health',
|
||||||
|
'armor',
|
||||||
|
'firepower',
|
||||||
|
'toughness',
|
||||||
|
'engineering',
|
||||||
|
'rolls',
|
||||||
|
'augmentSlots',
|
||||||
|
]) &&
|
||||||
|
['itemId', 'brandId', 'talentId'].every(
|
||||||
|
(key) => typeof value[key] === 'string',
|
||||||
|
) &&
|
||||||
|
isTalentTier(value.talentTier) &&
|
||||||
|
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
|
||||||
|
(key) => isFiniteNumber(value[key]),
|
||||||
|
) &&
|
||||||
|
isRollPair(value.rolls) &&
|
||||||
|
Array.isArray(value.augmentSlots) &&
|
||||||
|
value.augmentSlots.every(isFiniteNumber);
|
||||||
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
|
hasExactKeys(value, [
|
||||||
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
|
'itemId',
|
||||||
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
|
'level',
|
||||||
&& isRollPair(value.rolls);
|
'tier',
|
||||||
|
'damage',
|
||||||
|
'firepower',
|
||||||
|
'toughness',
|
||||||
|
'engineering',
|
||||||
|
'talent1Id',
|
||||||
|
'talent1Tier',
|
||||||
|
'talent2Id',
|
||||||
|
'talent2Tier',
|
||||||
|
'exoticTalentTier',
|
||||||
|
'rolls',
|
||||||
|
]) &&
|
||||||
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
||||||
|
(key) => typeof value[key] === 'string',
|
||||||
|
) &&
|
||||||
|
isTalentTier(value.talent1Tier) &&
|
||||||
|
isTalentTier(value.talent2Tier) &&
|
||||||
|
isTalentTier(value.exoticTalentTier) &&
|
||||||
|
['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every(
|
||||||
|
(key) => isFiniteNumber(value[key]),
|
||||||
|
) &&
|
||||||
|
isRollPair(value.rolls);
|
||||||
const isChipSelection = (value: unknown): value is ChipSelection =>
|
const isChipSelection = (value: unknown): value is ChipSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
|
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
|
||||||
&& typeof value.setId === 'string'
|
typeof value.setId === 'string' &&
|
||||||
&& (value.quality === 'Superior' || value.quality === 'High-end')
|
skillChipQualities.some((quality) => value.quality === quality) &&
|
||||||
&& isRoll(value.primary)
|
isRoll(value.primary) &&
|
||||||
&& isRollPair(value.secondary);
|
Array.isArray(value.secondary) &&
|
||||||
|
value.secondary.length ===
|
||||||
|
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
|
||||||
|
value.secondary.every(isRoll);
|
||||||
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
|
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
|
||||||
&& typeof value.itemId === 'string'
|
typeof value.itemId === 'string' &&
|
||||||
&& isFiniteNumber(value.level)
|
isFiniteNumber(value.level) &&
|
||||||
&& isFiniteNumber(value.talentUpgrades);
|
isFiniteNumber(value.talentUpgrades);
|
||||||
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
const isCombatProfile = (value: unknown): value is CombatProfile =>
|
||||||
isRecord(value)
|
isRecord(value) &&
|
||||||
&& hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
'encounterSeconds',
|
||||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
'hitRate',
|
||||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', 'extraHealthActive'
|
'headshotRate',
|
||||||
])
|
'shotsPerBurst',
|
||||||
&& [
|
'burstPauseSeconds',
|
||||||
'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds',
|
'sameTargetSeconds',
|
||||||
'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute',
|
'distinctTargetsPerMinute',
|
||||||
'swapsPerMinute', 'targetDistanceMeters', 'healthPercent'
|
'killsPerMinute',
|
||||||
].every((key) => isFiniteNumber(value[key]))
|
'headshotKillRate',
|
||||||
&& typeof value.extraHealthActive === 'boolean';
|
'damageTakenPerMinute',
|
||||||
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
|
'swapsPerMinute',
|
||||||
isRecord(value) && Object.values(value).every((entry) => {
|
'targetDistanceMeters',
|
||||||
if (!isRecord(entry)
|
'healthPercent',
|
||||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
'extraHealthActive',
|
||||||
const allowed = new Set(['mode', 'overrides', 'inputs']);
|
]) &&
|
||||||
if (Object.keys(entry).some((key) => !allowed.has(key))
|
[
|
||||||
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
|
'encounterSeconds',
|
||||||
if (entry.inputs !== undefined && (
|
'hitRate',
|
||||||
!isRecord(entry.inputs)
|
'headshotRate',
|
||||||
|| Object.values(entry.inputs).some((input) =>
|
'shotsPerBurst',
|
||||||
typeof input !== 'boolean' && !isFiniteNumber(input)
|
'burstPauseSeconds',
|
||||||
)
|
'sameTargetSeconds',
|
||||||
)) return false;
|
'distinctTargetsPerMinute',
|
||||||
return entry.overrides === undefined || (
|
'killsPerMinute',
|
||||||
isRecord(entry.overrides)
|
'headshotKillRate',
|
||||||
&& Object.entries(entry.overrides).every(([key, override]) =>
|
'damageTakenPerMinute',
|
||||||
key === 'extraHealthActive'
|
'swapsPerMinute',
|
||||||
? typeof override === 'boolean'
|
'targetDistanceMeters',
|
||||||
: key in defaultCombatProfile() && isFiniteNumber(override)
|
'healthPercent',
|
||||||
)
|
].every((key) => isFiniteNumber(value[key])) &&
|
||||||
);
|
typeof value.extraHealthActive === 'boolean';
|
||||||
});
|
const isEffectStates = (
|
||||||
const isSkillActivations = (value: unknown): value is Record<string, SkillActivationSelection> =>
|
value: unknown,
|
||||||
isRecord(value) && Object.values(value).every((entry) =>
|
): value is Record<string, ConditionalEffectSelection> =>
|
||||||
isRecord(entry)
|
isRecord(value) &&
|
||||||
&& ['off', 'on_cooldown', 'custom_per_minute'].includes(String(entry.mode))
|
Object.values(value).every((entry) => {
|
||||||
&& isFiniteNumber(entry.targets)
|
if (
|
||||||
&& (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute))
|
!isRecord(entry) ||
|
||||||
&& Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key))
|
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
||||||
);
|
)
|
||||||
|
return false;
|
||||||
|
const allowed = new Set(['mode', 'overrides', 'inputs']);
|
||||||
|
if (
|
||||||
|
Object.keys(entry).some((key) => !allowed.has(key)) ||
|
||||||
|
!['off', 'peak', 'encounter'].includes(String(entry.mode))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
entry.inputs !== undefined &&
|
||||||
|
(!isRecord(entry.inputs) ||
|
||||||
|
Object.values(entry.inputs).some(
|
||||||
|
(input) => typeof input !== 'boolean' && !isFiniteNumber(input),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return (
|
||||||
|
entry.overrides === undefined ||
|
||||||
|
(isRecord(entry.overrides) &&
|
||||||
|
Object.entries(entry.overrides).every(([key, override]) =>
|
||||||
|
key === 'extraHealthActive'
|
||||||
|
? typeof override === 'boolean'
|
||||||
|
: key in defaultCombatProfile() && isFiniteNumber(override),
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const isSkillActivations = (
|
||||||
|
value: unknown,
|
||||||
|
): value is Record<string, SkillActivationSelection> =>
|
||||||
|
isRecord(value) &&
|
||||||
|
Object.values(value).every(
|
||||||
|
(entry) =>
|
||||||
|
isRecord(entry) &&
|
||||||
|
['off', 'on_cooldown', 'custom_per_minute'].includes(
|
||||||
|
String(entry.mode),
|
||||||
|
) &&
|
||||||
|
isFiniteNumber(entry.targets) &&
|
||||||
|
(entry.usesPerMinute === undefined ||
|
||||||
|
isFiniteNumber(entry.usesPerMinute)) &&
|
||||||
|
Object.keys(entry).every((key) =>
|
||||||
|
['mode', 'usesPerMinute', 'targets'].includes(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
|
||||||
if (!isRecord(value)
|
if (
|
||||||
|| !hasExactKeys(value, [
|
!isRecord(value) ||
|
||||||
'version', 'buildName', 'calculationMode', 'gear', 'weapons', 'chips', 'protocol',
|
!hasExactKeys(value, [
|
||||||
'conditionalEffects', 'combatProfile', 'skillActivations', 'specialization'
|
'version',
|
||||||
])
|
'buildName',
|
||||||
|| value.version !== 4
|
'calculationMode',
|
||||||
|| typeof value.buildName !== 'string'
|
'gear',
|
||||||
|| !['maximum', 'custom'].includes(String(value.calculationMode))) return null;
|
'weapons',
|
||||||
const gear = value.gear;
|
'chips',
|
||||||
if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length
|
'protocol',
|
||||||
|| !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null;
|
'conditionalEffects',
|
||||||
if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null;
|
'combatProfile',
|
||||||
if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null;
|
'skillActivations',
|
||||||
if (!isProtocolSelection(value.protocol)
|
'specialization',
|
||||||
|| !isEffectStates(value.conditionalEffects)
|
]) ||
|
||||||
|| !isCombatProfile(value.combatProfile)
|
value.version !== 6 ||
|
||||||
|| !isSkillActivations(value.skillActivations)) return null;
|
typeof value.buildName !== 'string' ||
|
||||||
if (!isRecord(value.specialization)
|
!['maximum', 'custom'].includes(String(value.calculationMode))
|
||||||
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks'])
|
)
|
||||||
|| typeof value.specialization.focusId !== 'string'
|
return null;
|
||||||
|| !isNumberRecord(value.specialization.nodeRanks)) return null;
|
const gear = value.gear;
|
||||||
return structuredClone(value) as LoadoutDraft;
|
if (
|
||||||
|
!isRecord(gear) ||
|
||||||
|
Object.keys(gear).length !== gearSlots.length ||
|
||||||
|
!gearSlots.every((slot) => isGearSelection(gear[slot]))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
!Array.isArray(value.weapons) ||
|
||||||
|
value.weapons.length !== 2 ||
|
||||||
|
!value.weapons.every(isWeaponSelection)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
!Array.isArray(value.chips) ||
|
||||||
|
value.chips.length !== 9 ||
|
||||||
|
!value.chips.every(isChipSelection)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
!isProtocolSelection(value.protocol) ||
|
||||||
|
!isEffectStates(value.conditionalEffects) ||
|
||||||
|
!isCombatProfile(value.combatProfile) ||
|
||||||
|
!isSkillActivations(value.skillActivations)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (
|
||||||
|
!isRecord(value.specialization) ||
|
||||||
|
!hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) ||
|
||||||
|
typeof value.specialization.focusId !== 'string' ||
|
||||||
|
!isNumberRecord(value.specialization.nodeRanks)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return structuredClone(value) as LoadoutDraft;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
export const clamp = (value: unknown, min: unknown, max: unknown) => {
|
||||||
const low = Number(min), high = Number(max), numeric = Number(value);
|
const low = Number(min),
|
||||||
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
high = Number(max),
|
||||||
return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric));
|
numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
|
||||||
|
return Math.min(
|
||||||
|
Number.isFinite(high) ? high : numeric,
|
||||||
|
Math.max(Number.isFinite(low) ? low : numeric, numeric),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
|
export const scaled = (
|
||||||
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
|
min: unknown,
|
||||||
|
max: unknown,
|
||||||
|
progress: number,
|
||||||
|
steps: number,
|
||||||
|
) =>
|
||||||
|
Number(min) +
|
||||||
|
((Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress))) /
|
||||||
|
steps;
|
||||||
|
|
||||||
export type LevelScaledRange = {
|
export type LevelScaledRange = {
|
||||||
rawMin: number;
|
rawMin: number;
|
||||||
rawMax: number;
|
rawMax: number;
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
|
const itemLevel = (value: unknown) => Math.round(clamp(value, 1, 50));
|
||||||
export const gearMultiplier = (level: unknown) => 0.91 + 0.05 * itemLevel(level);
|
export const gearMultiplier = (level: unknown) =>
|
||||||
export const weaponMultiplier = (level: unknown) => 1.14 + 0.05 * itemLevel(level);
|
0.91 + 0.05 * itemLevel(level);
|
||||||
|
export const weaponMultiplier = (level: unknown) =>
|
||||||
|
1.14 + 0.05 * itemLevel(level);
|
||||||
|
|
||||||
export function gearBaseStatRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange {
|
export function gearBaseStatRange(
|
||||||
const target = itemLevel(targetLevel);
|
minimum: unknown,
|
||||||
const configuredMin = Number(minimum);
|
maximum: unknown,
|
||||||
const configuredMax = Number(maximum);
|
targetLevel: unknown,
|
||||||
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * gearMultiplier(target);
|
): LevelScaledRange {
|
||||||
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * gearMultiplier(target);
|
const target = itemLevel(targetLevel);
|
||||||
return {
|
const configuredMin = Number(minimum);
|
||||||
rawMin,
|
const configuredMax = Number(maximum);
|
||||||
rawMax,
|
const rawMin =
|
||||||
min: Math.floor(rawMin),
|
(Number.isFinite(configuredMin) ? configuredMin : 0) *
|
||||||
max: Math.floor(rawMax),
|
gearMultiplier(target);
|
||||||
provisional: target < 10
|
const rawMax =
|
||||||
};
|
(Number.isFinite(configuredMax) ? configuredMax : 0) *
|
||||||
|
gearMultiplier(target);
|
||||||
|
return {
|
||||||
|
rawMin,
|
||||||
|
rawMax,
|
||||||
|
min: Math.floor(rawMin),
|
||||||
|
max: Math.floor(rawMax),
|
||||||
|
provisional: target < 10,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function weaponBaseRange(minimum: unknown, maximum: unknown, targetLevel: unknown): LevelScaledRange {
|
export function weaponBaseRange(
|
||||||
const target = itemLevel(targetLevel);
|
minimum: unknown,
|
||||||
const configuredMin = Number(minimum);
|
maximum: unknown,
|
||||||
const configuredMax = Number(maximum);
|
targetLevel: unknown,
|
||||||
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * weaponMultiplier(target);
|
): LevelScaledRange {
|
||||||
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * weaponMultiplier(target);
|
const target = itemLevel(targetLevel);
|
||||||
return {
|
const configuredMin = Number(minimum);
|
||||||
rawMin,
|
const configuredMax = Number(maximum);
|
||||||
rawMax,
|
const rawMin =
|
||||||
min: Math.floor(rawMin),
|
(Number.isFinite(configuredMin) ? configuredMin : 0) *
|
||||||
max: Math.floor(rawMax),
|
weaponMultiplier(target);
|
||||||
provisional: target < 40
|
const rawMax =
|
||||||
};
|
(Number.isFinite(configuredMax) ? configuredMax : 0) *
|
||||||
|
weaponMultiplier(target);
|
||||||
|
return {
|
||||||
|
rawMin,
|
||||||
|
rawMax,
|
||||||
|
min: Math.floor(rawMin),
|
||||||
|
max: Math.floor(rawMax),
|
||||||
|
provisional: target < 40,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function levelScaledRange(
|
export function levelScaledRange(
|
||||||
minimum: unknown,
|
minimum: unknown,
|
||||||
maximum: unknown,
|
maximum: unknown,
|
||||||
targetLevel: unknown,
|
targetLevel: unknown,
|
||||||
referenceLevel: unknown,
|
referenceLevel: unknown,
|
||||||
source: 'gear' | 'weapon'
|
source: 'gear' | 'weapon',
|
||||||
): LevelScaledRange {
|
): LevelScaledRange {
|
||||||
const target = itemLevel(targetLevel);
|
const target = itemLevel(targetLevel);
|
||||||
const reference = itemLevel(referenceLevel);
|
const reference = itemLevel(referenceLevel);
|
||||||
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
|
const multiplier = source === 'gear' ? gearMultiplier : weaponMultiplier;
|
||||||
const ratio = multiplier(target) / multiplier(reference);
|
const ratio = multiplier(target) / multiplier(reference);
|
||||||
const configuredMin = Number(minimum);
|
const configuredMin = Number(minimum);
|
||||||
const configuredMax = Number(maximum);
|
const configuredMax = Number(maximum);
|
||||||
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
|
const rawMin = (Number.isFinite(configuredMin) ? configuredMin : 0) * ratio;
|
||||||
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
|
const rawMax = (Number.isFinite(configuredMax) ? configuredMax : 0) * ratio;
|
||||||
return {
|
return {
|
||||||
rawMin,
|
rawMin,
|
||||||
rawMax,
|
rawMax,
|
||||||
min: Math.floor(rawMin),
|
min: Math.floor(rawMin),
|
||||||
max: Math.floor(rawMax),
|
max: Math.floor(rawMax),
|
||||||
provisional: source === 'gear' ? target < 10 || reference < 10 : target < 40 || reference < 40
|
provisional:
|
||||||
};
|
source === 'gear'
|
||||||
|
? target < 10 || reference < 10
|
||||||
|
: target < 40 || reference < 40,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remapRangeValue(value: unknown, from: LevelScaledRange, to: LevelScaledRange) {
|
export function remapRangeValue(
|
||||||
const span = from.max - from.min;
|
value: unknown,
|
||||||
const position = span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
|
from: LevelScaledRange,
|
||||||
return Math.round(to.min + (to.max - to.min) * position);
|
to: LevelScaledRange,
|
||||||
|
) {
|
||||||
|
const span = from.max - from.min;
|
||||||
|
const position =
|
||||||
|
span > 0 ? (clamp(value, from.min, from.max) - from.min) / span : 0;
|
||||||
|
return Math.round(to.min + (to.max - to.min) * position);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
calculateWeaponBaseline,
|
||||||
|
type WeaponBaselineInput,
|
||||||
|
} from './weapon-baseline.js';
|
||||||
|
|
||||||
|
const input = (
|
||||||
|
overrides: Partial<WeaponBaselineInput> = {},
|
||||||
|
): WeaponBaselineInput => ({
|
||||||
|
weaponId: 'weapon-17',
|
||||||
|
weaponName: 'Evidence Rifle',
|
||||||
|
weaponLabel: 'PRIMARY',
|
||||||
|
selectedDamage: 12.75,
|
||||||
|
rpm: 137.5,
|
||||||
|
magazine: 31,
|
||||||
|
reloadSeconds: 2.35,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateWeaponBaseline', () => {
|
||||||
|
it('preserves decimal inputs in the selected and rated metrics', () => {
|
||||||
|
const baseline = calculateWeaponBaseline(input());
|
||||||
|
|
||||||
|
expect(baseline.selectedDamage).toEqual({ value: 12.75, missing: [] });
|
||||||
|
expect(baseline.ratedBodyHitThroughput).toEqual({
|
||||||
|
value: 29.21875,
|
||||||
|
missing: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps rated throughput available when reload inputs are missing', () => {
|
||||||
|
const baseline = calculateWeaponBaseline(
|
||||||
|
input({ magazine: undefined, reloadSeconds: undefined }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(baseline.ratedBodyHitThroughput).toEqual({
|
||||||
|
value: 29.21875,
|
||||||
|
missing: [],
|
||||||
|
});
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
|
||||||
|
value: null,
|
||||||
|
missing: [
|
||||||
|
'magazine is unavailable for weapon weapon-17',
|
||||||
|
'reloadSeconds is unavailable for weapon weapon-17',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the complete magazine-cycle formula without rounding', () => {
|
||||||
|
const baseline = calculateWeaponBaseline(
|
||||||
|
input({
|
||||||
|
selectedDamage: 13.25,
|
||||||
|
rpm: 97.5,
|
||||||
|
magazine: 17.5,
|
||||||
|
reloadSeconds: 2.75,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const expected = (13.25 * 17.5) / (17.5 / (97.5 / 60) + 2.75);
|
||||||
|
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
|
||||||
|
value: expected,
|
||||||
|
missing: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses selected damage exactly once at the no-double-scaling boundary', () => {
|
||||||
|
const baseline = calculateWeaponBaseline(
|
||||||
|
input({ selectedDamage: 12.5, rpm: 120, magazine: 10, reloadSeconds: 5 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(baseline.selectedDamage.value).toBe(12.5);
|
||||||
|
expect(baseline.ratedBodyHitThroughput.value).toBe(12.5 * (120 / 60));
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput.value).toBe(
|
||||||
|
(12.5 * 10) / (10 / (120 / 60) + 5),
|
||||||
|
);
|
||||||
|
expect(baseline.ratedBodyHitThroughput.value).not.toBe(
|
||||||
|
12.5 * 12.5 * (120 / 60),
|
||||||
|
);
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput.value).not.toBe(12.5 * 12.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
Number.NaN,
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
Number.NEGATIVE_INFINITY,
|
||||||
|
])('rejects invalid selected damage: %s', (selectedDamage) => {
|
||||||
|
const baseline = calculateWeaponBaseline(input({ selectedDamage }));
|
||||||
|
|
||||||
|
expect(baseline.selectedDamage.value).toBeNull();
|
||||||
|
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
|
||||||
|
expect(baseline.selectedDamage.missing[0]).toContain('selectedDamage');
|
||||||
|
expect(baseline.selectedDamage.missing[0]).toContain('weapon-17');
|
||||||
|
for (const metric of [
|
||||||
|
baseline.selectedDamage,
|
||||||
|
baseline.ratedBodyHitThroughput,
|
||||||
|
baseline.magazineCycleBodyHitThroughput,
|
||||||
|
]) {
|
||||||
|
expect(metric.value).toBeNull();
|
||||||
|
expect(metric.value).not.toBeNaN();
|
||||||
|
expect(metric.value).not.toBe(Infinity);
|
||||||
|
expect(metric.value).not.toBe(-Infinity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['rpm', null],
|
||||||
|
['rpm', 0],
|
||||||
|
['rpm', Number.NaN],
|
||||||
|
['magazine', -1],
|
||||||
|
['reloadSeconds', Number.POSITIVE_INFINITY],
|
||||||
|
] as const)(
|
||||||
|
'makes only dependent metrics unavailable for invalid %s',
|
||||||
|
(field, value) => {
|
||||||
|
const baseline = calculateWeaponBaseline(input({ [field]: value }));
|
||||||
|
|
||||||
|
expect(baseline.selectedDamage.value).toBe(12.75);
|
||||||
|
if (field === 'rpm') {
|
||||||
|
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
|
||||||
|
} else if (field === 'magazine' || field === 'reloadSeconds') {
|
||||||
|
expect(baseline.ratedBodyHitThroughput.value).toBe(29.21875);
|
||||||
|
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependent =
|
||||||
|
field === 'rpm'
|
||||||
|
? [
|
||||||
|
baseline.ratedBodyHitThroughput,
|
||||||
|
baseline.magazineCycleBodyHitThroughput,
|
||||||
|
]
|
||||||
|
: [baseline.magazineCycleBodyHitThroughput];
|
||||||
|
for (const metric of dependent) {
|
||||||
|
expect(metric.value).not.toBeNaN();
|
||||||
|
expect(metric.value).not.toBe(Infinity);
|
||||||
|
expect(metric.value).not.toBe(-Infinity);
|
||||||
|
expect(metric.missing[0]).toContain(field);
|
||||||
|
expect(metric.missing[0]).toContain('weapon-17');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
type NumericSource = number | null | undefined;
|
||||||
|
|
||||||
|
export type WeaponBaselineInput = {
|
||||||
|
weaponId: string;
|
||||||
|
weaponName: string;
|
||||||
|
weaponLabel: string;
|
||||||
|
selectedDamage?: NumericSource;
|
||||||
|
rpm?: NumericSource;
|
||||||
|
magazine?: NumericSource;
|
||||||
|
reloadSeconds?: NumericSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BaselineMetric = {
|
||||||
|
value: number | null;
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeaponBaseline = {
|
||||||
|
weaponId: string;
|
||||||
|
weaponName: string;
|
||||||
|
weaponLabel: string;
|
||||||
|
selectedDamage: BaselineMetric;
|
||||||
|
ratedBodyHitThroughput: BaselineMetric;
|
||||||
|
magazineCycleBodyHitThroughput: BaselineMetric;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPositiveFinite = (value: NumericSource): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||||
|
|
||||||
|
const missingField = (field: string, weaponId: string): string =>
|
||||||
|
`${field} is unavailable for weapon ${weaponId}`;
|
||||||
|
|
||||||
|
const unavailableMetric = (
|
||||||
|
fields: string[],
|
||||||
|
weaponId: string,
|
||||||
|
): BaselineMetric => ({
|
||||||
|
value: null,
|
||||||
|
missing: fields.map((field) => missingField(field, weaponId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculatedMetric = (
|
||||||
|
value: number,
|
||||||
|
metricField: string,
|
||||||
|
weaponId: string,
|
||||||
|
): BaselineMetric =>
|
||||||
|
Number.isFinite(value)
|
||||||
|
? { value, missing: [] }
|
||||||
|
: { value: null, missing: [missingField(metricField, weaponId)] };
|
||||||
|
|
||||||
|
export function calculateWeaponBaseline(
|
||||||
|
input: WeaponBaselineInput,
|
||||||
|
): WeaponBaseline {
|
||||||
|
const damageAvailable = isPositiveFinite(input.selectedDamage);
|
||||||
|
const rpmAvailable = isPositiveFinite(input.rpm);
|
||||||
|
const magazineAvailable = isPositiveFinite(input.magazine);
|
||||||
|
const reloadAvailable = isPositiveFinite(input.reloadSeconds);
|
||||||
|
const damage = damageAvailable ? (input.selectedDamage as number) : null;
|
||||||
|
const rpm = rpmAvailable ? (input.rpm as number) : null;
|
||||||
|
const magazine = magazineAvailable ? (input.magazine as number) : null;
|
||||||
|
const reloadSeconds = reloadAvailable
|
||||||
|
? (input.reloadSeconds as number)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const selectedDamage =
|
||||||
|
damage !== null
|
||||||
|
? calculatedMetric(damage, 'selectedDamage', input.weaponId)
|
||||||
|
: unavailableMetric(['selectedDamage'], input.weaponId);
|
||||||
|
|
||||||
|
const ratedBodyHitThroughput =
|
||||||
|
damage !== null && rpm !== null
|
||||||
|
? calculatedMetric(
|
||||||
|
(damage * rpm) / 60,
|
||||||
|
'ratedBodyHitThroughput',
|
||||||
|
input.weaponId,
|
||||||
|
)
|
||||||
|
: unavailableMetric(
|
||||||
|
[
|
||||||
|
...(!damageAvailable ? ['selectedDamage'] : []),
|
||||||
|
...(!rpmAvailable ? ['rpm'] : []),
|
||||||
|
],
|
||||||
|
input.weaponId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const magazineCycleBodyHitThroughput =
|
||||||
|
damage !== null &&
|
||||||
|
rpm !== null &&
|
||||||
|
magazine !== null &&
|
||||||
|
reloadSeconds !== null
|
||||||
|
? calculatedMetric(
|
||||||
|
(damage * magazine) / (magazine / (rpm / 60) + reloadSeconds),
|
||||||
|
'magazineCycleBodyHitThroughput',
|
||||||
|
input.weaponId,
|
||||||
|
)
|
||||||
|
: unavailableMetric(
|
||||||
|
[
|
||||||
|
...(!damageAvailable ? ['selectedDamage'] : []),
|
||||||
|
...(!rpmAvailable ? ['rpm'] : []),
|
||||||
|
...(!magazineAvailable ? ['magazine'] : []),
|
||||||
|
...(!reloadAvailable ? ['reloadSeconds'] : []),
|
||||||
|
],
|
||||||
|
input.weaponId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
weaponId: input.weaponId,
|
||||||
|
weaponName: input.weaponName,
|
||||||
|
weaponLabel: input.weaponLabel,
|
||||||
|
selectedDamage,
|
||||||
|
ratedBodyHitThroughput,
|
||||||
|
magazineCycleBodyHitThroughput,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,39 +1,66 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
formatItemDamageRangeScript,
|
formatItemDamageRangeScript,
|
||||||
formatWeaponCalculationScript
|
formatWeaponCalculationScript,
|
||||||
} from './calculation-script';
|
} from './calculation-script';
|
||||||
|
|
||||||
describe('formatWeaponCalculationScript', () => {
|
describe('formatWeaponCalculationScript', () => {
|
||||||
it('starts from the selected equipped damage and explains ordered typed stages', () => {
|
it('starts from the selected equipped damage and explains ordered typed stages', () => {
|
||||||
const lines = formatWeaponCalculationScript({
|
const lines = formatWeaponCalculationScript({
|
||||||
revision: { version: 4, name: 'Resurgence model' },
|
revision: { version: 4, name: 'Resurgence model' },
|
||||||
stages: [
|
stages: [
|
||||||
{ stage_key: 'base', label: 'Base damage', operator: 'base_damage', display_order: 1 },
|
{
|
||||||
{ stage_key: 'flat', label: 'Flat damage', operator: 'flat_add', display_order: 2 },
|
stage_key: 'base',
|
||||||
{ stage_key: 'cycle', label: 'Firing cycle', operator: 'firing_cycle', display_order: 3 }
|
label: 'Base damage',
|
||||||
],
|
operator: 'base_damage',
|
||||||
profiles: [{
|
display_order: 1,
|
||||||
stat_attribute_id: 'weapon-flat',
|
},
|
||||||
stage_key: 'flat',
|
{
|
||||||
weight: 1.5,
|
stage_key: 'flat',
|
||||||
soft_cap: null,
|
label: 'Flat damage',
|
||||||
hard_cap: 100,
|
operator: 'flat_add',
|
||||||
curve_type: 'none',
|
display_order: 2,
|
||||||
curve_parameter: null,
|
},
|
||||||
weapon_category: 'SMG'
|
{
|
||||||
}],
|
stage_key: 'cycle',
|
||||||
attributes: [{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null }]
|
label: 'Firing cycle',
|
||||||
});
|
operator: 'firing_cycle',
|
||||||
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
|
display_order: 3,
|
||||||
expect(lines).toContain('ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage');
|
},
|
||||||
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
|
],
|
||||||
});
|
profiles: [
|
||||||
|
{
|
||||||
|
stat_attribute_id: 'weapon-flat',
|
||||||
|
stage_key: 'flat',
|
||||||
|
weight: 1.5,
|
||||||
|
soft_cap: null,
|
||||||
|
hard_cap: 100,
|
||||||
|
curve_type: 'none',
|
||||||
|
curve_parameter: null,
|
||||||
|
weapon_category: 'SMG',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attributes: [
|
||||||
|
{ id: 'weapon-flat', name: 'Flat Weapon Damage', unit: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(lines).toContain('TAKE equipped_weapon.damage AS base_damage');
|
||||||
|
expect(lines).toContain(
|
||||||
|
'ADD flat_weapon_damage × 1.5 HARD_CAP 100 FOR SMG TO damage',
|
||||||
|
);
|
||||||
|
expect(lines.at(-1)).toBe('RETURN expected_damage_per_second');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps level scaling in item configuration instead of applying it twice', () => {
|
it('keeps level scaling in item configuration instead of applying it twice', () => {
|
||||||
const lines = formatItemDamageRangeScript();
|
const lines = formatItemDamageRangeScript();
|
||||||
expect(lines).toContain('SCALE configured_damage_range TO equipped_weapon.level');
|
expect(lines).toContain(
|
||||||
expect(lines).toContain('PASS equipped_weapon.damage TO combat_calculation');
|
'SCALE configured_damage_range TO equipped_weapon.level',
|
||||||
expect(lines).not.toContain('SCALE equipped_weapon.damage TO equipped_weapon.level');
|
);
|
||||||
});
|
expect(lines).toContain(
|
||||||
|
'PASS equipped_weapon.damage TO combat_calculation',
|
||||||
|
);
|
||||||
|
expect(lines).not.toContain(
|
||||||
|
'SCALE equipped_weapon.damage TO equipped_weapon.level',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,131 +1,147 @@
|
|||||||
export type CalculationScriptStage = {
|
export type CalculationScriptStage = {
|
||||||
stage_key: string;
|
stage_key: string;
|
||||||
label: string;
|
label: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalculationScriptProfile = {
|
export type CalculationScriptProfile = {
|
||||||
stat_attribute_id: string;
|
stat_attribute_id: string;
|
||||||
stage_key: string;
|
stage_key: string;
|
||||||
weight?: number | null;
|
weight?: number | null;
|
||||||
soft_cap?: number | null;
|
soft_cap?: number | null;
|
||||||
hard_cap?: number | null;
|
hard_cap?: number | null;
|
||||||
curve_type?: string | null;
|
curve_type?: string | null;
|
||||||
curve_parameter?: number | null;
|
curve_parameter?: number | null;
|
||||||
weapon_category?: string | null;
|
weapon_category?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalculationScriptAttribute = {
|
export type CalculationScriptAttribute = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalculationScriptRevision = {
|
export type CalculationScriptRevision = {
|
||||||
version?: number | null;
|
version?: number | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const number = (value: unknown) =>
|
const number = (value: unknown) =>
|
||||||
Number.isInteger(Number(value)) ? String(Number(value)) : String(Number(Number(value).toFixed(3)));
|
Number.isInteger(Number(value))
|
||||||
|
? String(Number(value))
|
||||||
|
: String(Number(Number(value).toFixed(3)));
|
||||||
|
|
||||||
const identifier = (value: unknown) => String(value ?? '')
|
const identifier = (value: unknown) =>
|
||||||
.normalize('NFKC')
|
String(value ?? '')
|
||||||
.trim()
|
.normalize('NFKC')
|
||||||
.toLowerCase()
|
.trim()
|
||||||
.replace(/[^a-z0-9]+/g, '_')
|
.toLowerCase()
|
||||||
.replace(/^_+|_+$/g, '') || 'unnamed';
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '') || 'unnamed';
|
||||||
|
|
||||||
function profileExpression(
|
function profileExpression(
|
||||||
profile: CalculationScriptProfile,
|
profile: CalculationScriptProfile,
|
||||||
attributes: Map<string, CalculationScriptAttribute>
|
attributes: Map<string, CalculationScriptAttribute>,
|
||||||
) {
|
) {
|
||||||
const attribute = attributes.get(profile.stat_attribute_id);
|
const attribute = attributes.get(profile.stat_attribute_id);
|
||||||
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
|
const unit = attribute?.unit ? ` ${attribute.unit}` : '';
|
||||||
const weight = Number(profile.weight ?? 1);
|
const weight = Number(profile.weight ?? 1);
|
||||||
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
|
let expression = `${identifier(attribute?.name ?? profile.stat_attribute_id)}${unit}`;
|
||||||
if (weight !== 1) expression += ` × ${number(weight)}`;
|
if (weight !== 1) expression += ` × ${number(weight)}`;
|
||||||
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
|
if (profile.soft_cap !== null && profile.soft_cap !== undefined) {
|
||||||
const curve = profile.curve_type && profile.curve_type !== 'none'
|
const curve =
|
||||||
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
|
profile.curve_type && profile.curve_type !== 'none'
|
||||||
: '';
|
? ` USING ${String(profile.curve_type).toUpperCase()}(${number(profile.curve_parameter ?? 1)})`
|
||||||
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
|
: '';
|
||||||
}
|
expression += ` SOFT_CAP ${number(profile.soft_cap)}${curve}`;
|
||||||
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
|
}
|
||||||
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
|
if (profile.hard_cap !== null && profile.hard_cap !== undefined) {
|
||||||
}
|
expression += ` HARD_CAP ${number(profile.hard_cap)}`;
|
||||||
if (profile.weapon_category && profile.weapon_category !== '*') {
|
}
|
||||||
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
|
if (profile.weapon_category && profile.weapon_category !== '*') {
|
||||||
}
|
expression += ` FOR ${String(profile.weapon_category).toUpperCase()}`;
|
||||||
return expression;
|
}
|
||||||
|
return expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
const operatorStatement = (operator: string, label: string, sources: string[]) => {
|
const operatorStatement = (
|
||||||
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
|
operator: string,
|
||||||
switch (operator) {
|
label: string,
|
||||||
case 'base_damage':
|
sources: string[],
|
||||||
return ['TAKE equipped_weapon.damage AS base_damage'];
|
) => {
|
||||||
case 'flat_add':
|
const source = sources.length ? sources.join(', ') : 'no_bound_attributes';
|
||||||
return [`ADD ${source} TO damage`];
|
switch (operator) {
|
||||||
case 'additive_percent':
|
case 'base_damage':
|
||||||
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
|
return ['TAKE equipped_weapon.damage AS base_damage'];
|
||||||
case 'multiplicative_percent':
|
case 'flat_add':
|
||||||
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
|
return [`ADD ${source} TO damage`];
|
||||||
case 'expected_critical':
|
case 'additive_percent':
|
||||||
return [
|
return [`APPLY SUM(${source}) AS additive_percent TO damage`];
|
||||||
`WEIGHT critical_damage BY critical_chance`,
|
case 'multiplicative_percent':
|
||||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
return [`APPLY EACH(${source}) AS multiplicative_percent TO damage`];
|
||||||
];
|
case 'expected_critical':
|
||||||
case 'expected_headshot':
|
return [
|
||||||
return [
|
`WEIGHT critical_damage BY critical_chance`,
|
||||||
`WEIGHT headshot_damage BY headshot_chance`,
|
...(sources.length ? [`SOURCE ${source}`] : []),
|
||||||
...(sources.length ? [`SOURCE ${source}`] : [])
|
];
|
||||||
];
|
case 'expected_headshot':
|
||||||
case 'rate_of_fire':
|
return [
|
||||||
return [`ADJUST equipped_weapon.rpm USING ${source}`];
|
`WEIGHT headshot_damage BY headshot_chance`,
|
||||||
case 'magazine_size':
|
...(sources.length ? [`SOURCE ${source}`] : []),
|
||||||
return [`ADJUST equipped_weapon.magazine USING ${source}`];
|
];
|
||||||
case 'reload_speed':
|
case 'rate_of_fire':
|
||||||
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
|
return [`ADJUST equipped_weapon.rpm USING ${source}`];
|
||||||
case 'firing_cycle':
|
case 'magazine_size':
|
||||||
return [
|
return [`ADJUST equipped_weapon.magazine USING ${source}`];
|
||||||
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
|
case 'reload_speed':
|
||||||
'RETURN expected_damage_per_second'
|
return [`ADJUST equipped_weapon.reload_seconds USING ${source}`];
|
||||||
];
|
case 'firing_cycle':
|
||||||
default:
|
return [
|
||||||
return [`APPLY ${identifier(label)} USING ${source}`];
|
'CALCULATE firing_cycle FROM rpm, magazine, reload_seconds',
|
||||||
}
|
'RETURN expected_damage_per_second',
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [`APPLY ${identifier(label)} USING ${source}`];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatWeaponCalculationScript(input: {
|
export function formatWeaponCalculationScript(input: {
|
||||||
revision?: CalculationScriptRevision | null;
|
revision?: CalculationScriptRevision | null;
|
||||||
stages: CalculationScriptStage[];
|
stages: CalculationScriptStage[];
|
||||||
profiles: CalculationScriptProfile[];
|
profiles: CalculationScriptProfile[];
|
||||||
attributes: CalculationScriptAttribute[];
|
attributes: CalculationScriptAttribute[];
|
||||||
}) {
|
}) {
|
||||||
const attributes = new Map(input.attributes.map((attribute) => [String(attribute.id), attribute]));
|
const attributes = new Map(
|
||||||
const lines = [
|
input.attributes.map((attribute) => [String(attribute.id), attribute]),
|
||||||
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`
|
);
|
||||||
];
|
const lines = [
|
||||||
for (const stage of [...input.stages].sort(
|
`CALCULATE expected_weapon_dps${input.revision?.version ? ` WITH rules_r${input.revision.version}` : ''}`,
|
||||||
(left, right) => Number(left.display_order) - Number(right.display_order)
|
];
|
||||||
)) {
|
for (const stage of [...input.stages].sort(
|
||||||
const sources = input.profiles
|
(left, right) => Number(left.display_order) - Number(right.display_order),
|
||||||
.filter((profile) => profile.stage_key === stage.stage_key)
|
)) {
|
||||||
.map((profile) => profileExpression(profile, attributes));
|
const sources = input.profiles
|
||||||
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
|
.filter((profile) => profile.stage_key === stage.stage_key)
|
||||||
lines.push(...operatorStatement(String(stage.operator), String(stage.label), sources));
|
.map((profile) => profileExpression(profile, attributes));
|
||||||
}
|
lines.push('', `STAGE ${String(stage.label).toUpperCase()}`);
|
||||||
return lines;
|
lines.push(
|
||||||
|
...operatorStatement(
|
||||||
|
String(stage.operator),
|
||||||
|
String(stage.label),
|
||||||
|
sources,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatItemDamageRangeScript() {
|
export function formatItemDamageRangeScript() {
|
||||||
return [
|
return [
|
||||||
'CONFIGURE equipped_weapon',
|
'CONFIGURE equipped_weapon',
|
||||||
'READ configured_damage_range AT level_1',
|
'READ configured_damage_range AT level_1',
|
||||||
'SCALE configured_damage_range TO equipped_weapon.level',
|
'SCALE configured_damage_range TO equipped_weapon.level',
|
||||||
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
|
'SELECT equipped_weapon.damage WITHIN scaled_damage_range',
|
||||||
'PASS equipped_weapon.damage TO combat_calculation'
|
'PASS equipped_weapon.damage TO combat_calculation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-126
@@ -1,141 +1,236 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
calculateCombat,
|
calculateCombat,
|
||||||
expectedWeaponShotDamage,
|
expectedWeaponShotDamage,
|
||||||
type CombatCalculationInput
|
type CombatCalculationInput,
|
||||||
} from './combat.js';
|
} from './combat.js';
|
||||||
|
|
||||||
const base = (overrides: Partial<CombatCalculationInput> = {}): CombatCalculationInput => ({
|
const base = (
|
||||||
weapon: { baseDamage: 100, rpm: 600, magazine: 10, reloadSeconds: 2, headshotMultiplier: 2 },
|
overrides: Partial<CombatCalculationInput> = {},
|
||||||
attributes: { weapon: 20, critChance: 25, critDamage: 50, headshotChance: 0, final: 10 },
|
): CombatCalculationInput => ({
|
||||||
bindings: [
|
weapon: {
|
||||||
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
baseDamage: 100,
|
||||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
rpm: 600,
|
||||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
magazine: 10,
|
||||||
{ attributeId: 'headshotChance', stage: 'headshot_chance', operator: 'add' },
|
reloadSeconds: 2,
|
||||||
{ attributeId: 'final', stage: 'final_damage', operator: 'add' }
|
headshotMultiplier: 2,
|
||||||
],
|
},
|
||||||
assumptions: { mode: 'off', hitRate: 100, headshotRate: 0, firingActivity: 100, encounterSeconds: 20 },
|
attributes: {
|
||||||
...overrides
|
weapon: 20,
|
||||||
|
critChance: 25,
|
||||||
|
critDamage: 50,
|
||||||
|
headshotChance: 0,
|
||||||
|
final: 10,
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{ attributeId: 'weapon', stage: 'weapon_damage', operator: 'add' },
|
||||||
|
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
||||||
|
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' },
|
||||||
|
{
|
||||||
|
attributeId: 'headshotChance',
|
||||||
|
stage: 'headshot_chance',
|
||||||
|
operator: 'add',
|
||||||
|
},
|
||||||
|
{ attributeId: 'final', stage: 'final_damage', operator: 'add' },
|
||||||
|
],
|
||||||
|
assumptions: {
|
||||||
|
mode: 'off',
|
||||||
|
hitRate: 100,
|
||||||
|
headshotRate: 0,
|
||||||
|
firingActivity: 100,
|
||||||
|
encounterSeconds: 20,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateCombat', () => {
|
describe('calculateCombat', () => {
|
||||||
it('calculates four hit values and reload-aware DPS', () => {
|
it('calculates four hit values and reload-aware DPS', () => {
|
||||||
const result = calculateCombat(base());
|
const result = calculateCombat(base());
|
||||||
expect(result.status).toBe('OK');
|
expect(result.status).toBe('OK');
|
||||||
expect(result.hitValues).toEqual({ normal: 132, critical: 198, headshot: 264, criticalHeadshot: 396 });
|
expect(result.hitValues).toEqual({
|
||||||
expect(result.burstDps).toBe(1485);
|
normal: 132,
|
||||||
expect(result.sustainedDps).toBeCloseTo(495);
|
critical: 198,
|
||||||
});
|
headshot: 264,
|
||||||
|
criticalHeadshot: 396,
|
||||||
|
});
|
||||||
|
expect(result.burstDps).toBe(1485);
|
||||||
|
expect(result.sustainedDps).toBeCloseTo(495);
|
||||||
|
});
|
||||||
|
|
||||||
it('calculates expected body and headshot damage for timeline events', () => {
|
it('calculates expected body and headshot damage for timeline events', () => {
|
||||||
const input = base({
|
const input = base({
|
||||||
attributes: { critChance: 50, critDamage: 100 },
|
attributes: { critChance: 50, critDamage: 100 },
|
||||||
bindings: [
|
bindings: [
|
||||||
{ attributeId: 'critChance', stage: 'critical_chance', operator: 'add' },
|
{
|
||||||
{ attributeId: 'critDamage', stage: 'critical_damage', operator: 'add' }
|
attributeId: 'critChance',
|
||||||
]
|
stage: 'critical_chance',
|
||||||
});
|
operator: 'add',
|
||||||
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings)).toBe(150);
|
},
|
||||||
expect(expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings, true)).toBe(300);
|
{
|
||||||
});
|
attributeId: 'critDamage',
|
||||||
|
stage: 'critical_damage',
|
||||||
|
operator: 'add',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
expectedWeaponShotDamage(input.weapon, input.attributes, input.bindings),
|
||||||
|
).toBe(150);
|
||||||
|
expect(
|
||||||
|
expectedWeaponShotDamage(
|
||||||
|
input.weapon,
|
||||||
|
input.attributes,
|
||||||
|
input.bindings,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies weights, hard caps, and a configured curve safely', () => {
|
it('applies weights, hard caps, and a configured curve safely', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
attributes: { weapon: 100 },
|
base({
|
||||||
bindings: [{
|
attributes: { weapon: 100 },
|
||||||
attributeId: 'weapon',
|
bindings: [
|
||||||
stage: 'weapon_damage',
|
{
|
||||||
operator: 'add',
|
attributeId: 'weapon',
|
||||||
weight: 2,
|
stage: 'weapon_damage',
|
||||||
softCap: 50,
|
operator: 'add',
|
||||||
hardCap: 80,
|
weight: 2,
|
||||||
curveType: 'power',
|
softCap: 50,
|
||||||
curveParameter: 1
|
hardCap: 80,
|
||||||
}]
|
curveType: 'power',
|
||||||
}));
|
curveParameter: 1,
|
||||||
expect(result.hitValues?.normal).toBe(180);
|
},
|
||||||
});
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.hitValues?.normal).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies firing-cycle attributes to burst and sustained DPS', () => {
|
it('applies firing-cycle attributes to burst and sustained DPS', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
attributes: { rate: 20, magazine: 100, reload: 100 },
|
base({
|
||||||
bindings: [
|
attributes: { rate: 20, magazine: 100, reload: 100 },
|
||||||
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
|
bindings: [
|
||||||
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
|
{ attributeId: 'rate', stage: 'rate_of_fire', operator: 'add' },
|
||||||
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' }
|
{ attributeId: 'magazine', stage: 'magazine_size', operator: 'add' },
|
||||||
]
|
{ attributeId: 'reload', stage: 'reload_speed', operator: 'add' },
|
||||||
}));
|
],
|
||||||
expect(result.status).toBe('OK');
|
}),
|
||||||
expect(result.burstDps).toBe(1200);
|
);
|
||||||
expect(result.sustainedDps).toBeCloseTo(750);
|
expect(result.status).toBe('OK');
|
||||||
});
|
expect(result.burstDps).toBe(1200);
|
||||||
|
expect(result.sustainedDps).toBeCloseTo(750);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
it('keeps effects off, applies peak stacks, and honors per-effect overrides', () => {
|
||||||
const stackingEffects = [{
|
const stackingEffects = [
|
||||||
id: 'relentless',
|
{
|
||||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
id: 'relentless',
|
||||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||||
attributes: { weapon: 3, critDamage: 1 }
|
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
||||||
}];
|
attributes: { weapon: 3, critDamage: 1 },
|
||||||
const off = calculateCombat(base({ stackingEffects }));
|
},
|
||||||
const peak = calculateCombat(base({
|
];
|
||||||
stackingEffects,
|
const off = calculateCombat(base({ stackingEffects }));
|
||||||
assumptions: { mode: 'off', perEffect: { relentless: { mode: 'peak' } } }
|
const peak = calculateCombat(
|
||||||
}));
|
base({
|
||||||
expect(off.hitValues?.normal).toBe(132);
|
stackingEffects,
|
||||||
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
assumptions: {
|
||||||
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
mode: 'off',
|
||||||
});
|
perEffect: { relentless: { mode: 'peak' } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(off.hitValues?.normal).toBe(132);
|
||||||
|
expect(peak.hitValues?.normal).toBeCloseTo(165);
|
||||||
|
expect(peak.stackSummaries[0].stacksAtEnd).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
it('models encounter stack ramp and refresh with the weapon firing cycle', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
stackingEffects: [{
|
base({
|
||||||
id: 'relentless',
|
stackingEffects: [
|
||||||
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
{
|
||||||
lifecycle: { maxStacks: 10, durationSeconds: 10, stacksPerTrigger: 1 },
|
id: 'relentless',
|
||||||
attributes: { weapon: 3 }
|
trigger: { hitsRequired: 9, windowSeconds: 3 },
|
||||||
}],
|
lifecycle: {
|
||||||
assumptions: { mode: 'encounter', hitRate: 100, firingActivity: 100, encounterSeconds: 30 }
|
maxStacks: 10,
|
||||||
}));
|
durationSeconds: 10,
|
||||||
expect(result.encounterDps).not.toBeNull();
|
stacksPerTrigger: 1,
|
||||||
expect(result.encounterDps!).toBeGreaterThan(495);
|
},
|
||||||
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
attributes: { weapon: 3 },
|
||||||
expect(result.stackSummaries[0].maintained).toBe(true);
|
},
|
||||||
});
|
],
|
||||||
|
assumptions: {
|
||||||
|
mode: 'encounter',
|
||||||
|
hitRate: 100,
|
||||||
|
firingActivity: 100,
|
||||||
|
encounterSeconds: 30,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.encounterDps).not.toBeNull();
|
||||||
|
expect(result.encounterDps!).toBeGreaterThan(495);
|
||||||
|
expect(result.stackSummaries[0].maxStacksReached).toBe(10);
|
||||||
|
expect(result.stackSummaries[0].maintained).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
it('does not trigger when expected hits cannot meet the trigger window', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
stackingEffects: [{
|
base({
|
||||||
id: 'slow',
|
stackingEffects: [
|
||||||
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
{
|
||||||
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
id: 'slow',
|
||||||
attributes: { weapon: 3 }
|
trigger: { hitsRequired: 9, windowSeconds: 0.5 },
|
||||||
}],
|
lifecycle: { maxStacks: 10, durationSeconds: 10 },
|
||||||
assumptions: { mode: 'encounter', hitRate: 10, firingActivity: 50, encounterSeconds: 20 }
|
attributes: { weapon: 3 },
|
||||||
}));
|
},
|
||||||
expect(result.stackSummaries[0]).toMatchObject({ maxStacksReached: 0, averageStacks: 0, maintained: false });
|
],
|
||||||
});
|
assumptions: {
|
||||||
|
mode: 'encounter',
|
||||||
|
hitRate: 10,
|
||||||
|
firingActivity: 50,
|
||||||
|
encounterSeconds: 20,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.stackSummaries[0]).toMatchObject({
|
||||||
|
maxStacksReached: 0,
|
||||||
|
averageStacks: 0,
|
||||||
|
maintained: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns explicit requirements instead of partial results', () => {
|
it('returns explicit requirements instead of partial results', () => {
|
||||||
const result = calculateCombat(base({
|
const result = calculateCombat(
|
||||||
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
base({
|
||||||
assumptions: { mode: 'encounter' },
|
weapon: { baseDamage: 100, rpm: 0, magazine: 0, reloadSeconds: -1 },
|
||||||
stackingEffects: [{
|
assumptions: { mode: 'encounter' },
|
||||||
id: 'need-profile',
|
stackingEffects: [
|
||||||
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
{
|
||||||
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
id: 'need-profile',
|
||||||
attributes: {}
|
trigger: { hitsRequired: 1, windowSeconds: 1 },
|
||||||
}]
|
lifecycle: { maxStacks: 1, durationSeconds: 1 },
|
||||||
}));
|
attributes: {},
|
||||||
expect(result).toMatchObject({ status: 'INSUFFICIENT_DATA', hitValues: null, burstDps: null });
|
},
|
||||||
expect(result.missing).toEqual(expect.arrayContaining([
|
],
|
||||||
'weapon.rpm',
|
}),
|
||||||
'weapon.magazine',
|
);
|
||||||
'weapon.reloadSeconds',
|
expect(result).toMatchObject({
|
||||||
'assumptions.hitRate',
|
status: 'INSUFFICIENT_DATA',
|
||||||
'assumptions.firingActivity',
|
hitValues: null,
|
||||||
'assumptions.encounterSeconds'
|
burstDps: null,
|
||||||
]));
|
});
|
||||||
});
|
expect(result.missing).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'weapon.rpm',
|
||||||
|
'weapon.magazine',
|
||||||
|
'weapon.reloadSeconds',
|
||||||
|
'assumptions.hitRate',
|
||||||
|
'assumptions.firingActivity',
|
||||||
|
'assumptions.encounterSeconds',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+525
-390
@@ -1,465 +1,600 @@
|
|||||||
export type CalculationStage =
|
export type CalculationStage =
|
||||||
| 'flat_damage'
|
| 'flat_damage'
|
||||||
| 'weapon_damage'
|
| 'weapon_damage'
|
||||||
| 'critical_chance'
|
| 'critical_chance'
|
||||||
| 'critical_damage'
|
| 'critical_damage'
|
||||||
| 'headshot_chance'
|
| 'headshot_chance'
|
||||||
| 'headshot_damage'
|
| 'headshot_damage'
|
||||||
| 'rate_of_fire'
|
| 'rate_of_fire'
|
||||||
| 'magazine_size'
|
| 'magazine_size'
|
||||||
| 'reload_speed'
|
| 'reload_speed'
|
||||||
| 'final_damage';
|
| 'final_damage';
|
||||||
export type CalculationOperator = 'add' | 'multiply';
|
export type CalculationOperator = 'add' | 'multiply';
|
||||||
export type CurveType = 'none' | 'power' | 'smoothstep';
|
export type CurveType = 'none' | 'power' | 'smoothstep';
|
||||||
export type AttributeBinding = {
|
export type AttributeBinding = {
|
||||||
attributeId: string;
|
attributeId: string;
|
||||||
stage: CalculationStage;
|
stage: CalculationStage;
|
||||||
operator: CalculationOperator;
|
operator: CalculationOperator;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
softCap?: number | null;
|
softCap?: number | null;
|
||||||
hardCap?: number | null;
|
hardCap?: number | null;
|
||||||
curveType?: CurveType;
|
curveType?: CurveType;
|
||||||
curveParameter?: number | null;
|
curveParameter?: number | null;
|
||||||
};
|
};
|
||||||
export type StatValues = Record<string, number>;
|
export type StatValues = Record<string, number>;
|
||||||
export type WeaponCombatInput = {
|
export type WeaponCombatInput = {
|
||||||
baseDamage?: number;
|
baseDamage?: number;
|
||||||
rpm?: number;
|
rpm?: number;
|
||||||
magazine?: number;
|
magazine?: number;
|
||||||
reloadSeconds?: number;
|
reloadSeconds?: number;
|
||||||
headshotMultiplier?: number;
|
headshotMultiplier?: number;
|
||||||
};
|
};
|
||||||
export type WeaponFiringCycle = {
|
export type WeaponFiringCycle = {
|
||||||
rpm: number;
|
rpm: number;
|
||||||
magazine: number;
|
magazine: number;
|
||||||
reloadSeconds: number;
|
reloadSeconds: number;
|
||||||
};
|
};
|
||||||
export type StackingMode = 'off' | 'peak' | 'encounter';
|
export type StackingMode = 'off' | 'peak' | 'encounter';
|
||||||
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
|
export type TriggerRule = { hitsRequired: number; windowSeconds: number };
|
||||||
export type StackLifecycle = { maxStacks: number; durationSeconds: number; stacksPerTrigger?: number };
|
export type StackLifecycle = {
|
||||||
|
maxStacks: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
stacksPerTrigger?: number;
|
||||||
|
};
|
||||||
export type StackingEffect = {
|
export type StackingEffect = {
|
||||||
id: string;
|
id: string;
|
||||||
trigger: TriggerRule;
|
trigger: TriggerRule;
|
||||||
lifecycle: StackLifecycle;
|
lifecycle: StackLifecycle;
|
||||||
attributes: StatValues;
|
attributes: StatValues;
|
||||||
};
|
};
|
||||||
export type CombatAssumptions = {
|
export type CombatAssumptions = {
|
||||||
mode: StackingMode;
|
mode: StackingMode;
|
||||||
hitRate?: number;
|
hitRate?: number;
|
||||||
headshotRate?: number;
|
headshotRate?: number;
|
||||||
firingActivity?: number;
|
firingActivity?: number;
|
||||||
encounterSeconds?: number;
|
encounterSeconds?: number;
|
||||||
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
perEffect?: Record<string, Partial<Omit<CombatAssumptions, 'perEffect'>>>;
|
||||||
};
|
};
|
||||||
export type MissingRequirement =
|
export type MissingRequirement =
|
||||||
| 'weapon.baseDamage'
|
| 'weapon.baseDamage'
|
||||||
| 'weapon.rpm'
|
| 'weapon.rpm'
|
||||||
| 'weapon.magazine'
|
| 'weapon.magazine'
|
||||||
| 'weapon.reloadSeconds'
|
| 'weapon.reloadSeconds'
|
||||||
| 'assumptions.hitRate'
|
| 'assumptions.hitRate'
|
||||||
| 'assumptions.firingActivity'
|
| 'assumptions.firingActivity'
|
||||||
| 'assumptions.encounterSeconds'
|
| 'assumptions.encounterSeconds'
|
||||||
| `binding:${string}`;
|
| `binding:${string}`;
|
||||||
export type DamageValues = {
|
export type DamageValues = {
|
||||||
normal: number;
|
normal: number;
|
||||||
critical: number;
|
critical: number;
|
||||||
headshot: number;
|
headshot: number;
|
||||||
criticalHeadshot: number;
|
criticalHeadshot: number;
|
||||||
};
|
};
|
||||||
export type StackSummary = {
|
export type StackSummary = {
|
||||||
effectId: string;
|
effectId: string;
|
||||||
mode: StackingMode;
|
mode: StackingMode;
|
||||||
stacksAtEnd: number;
|
stacksAtEnd: number;
|
||||||
maxStacksReached: number;
|
maxStacksReached: number;
|
||||||
buildUpSeconds: number | null;
|
buildUpSeconds: number | null;
|
||||||
averageStacks: number;
|
averageStacks: number;
|
||||||
maintained: boolean;
|
maintained: boolean;
|
||||||
};
|
};
|
||||||
export type CombatCalculation = {
|
export type CombatCalculation = {
|
||||||
status: 'OK' | 'INSUFFICIENT_DATA';
|
status: 'OK' | 'INSUFFICIENT_DATA';
|
||||||
missing: MissingRequirement[];
|
missing: MissingRequirement[];
|
||||||
hitValues: DamageValues | null;
|
hitValues: DamageValues | null;
|
||||||
burstDps: number | null;
|
burstDps: number | null;
|
||||||
sustainedDps: number | null;
|
sustainedDps: number | null;
|
||||||
encounterDps: number | null;
|
encounterDps: number | null;
|
||||||
stackSummaries: StackSummary[];
|
stackSummaries: StackSummary[];
|
||||||
};
|
};
|
||||||
export type CombatCalculationInput = {
|
export type CombatCalculationInput = {
|
||||||
weapon: WeaponCombatInput;
|
weapon: WeaponCombatInput;
|
||||||
attributes: StatValues;
|
attributes: StatValues;
|
||||||
bindings: AttributeBinding[];
|
bindings: AttributeBinding[];
|
||||||
stageOrder?: CalculationStage[];
|
stageOrder?: CalculationStage[];
|
||||||
stackingEffects?: StackingEffect[];
|
stackingEffects?: StackingEffect[];
|
||||||
assumptions: CombatAssumptions;
|
assumptions: CombatAssumptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedAssumptions = {
|
type ResolvedAssumptions = {
|
||||||
mode: StackingMode;
|
mode: StackingMode;
|
||||||
hitRate: number;
|
hitRate: number;
|
||||||
headshotRate: number;
|
headshotRate: number;
|
||||||
firingActivity: number;
|
firingActivity: number;
|
||||||
encounterSeconds: number;
|
encounterSeconds: number;
|
||||||
};
|
};
|
||||||
type StackState = {
|
type StackState = {
|
||||||
stacks: number;
|
stacks: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
maxStacksReached: number;
|
maxStacksReached: number;
|
||||||
buildUpSeconds: number | null;
|
buildUpSeconds: number | null;
|
||||||
};
|
};
|
||||||
type TimedStackEffect = {
|
type TimedStackEffect = {
|
||||||
effect: StackingEffect;
|
effect: StackingEffect;
|
||||||
assumptions: ResolvedAssumptions;
|
assumptions: ResolvedAssumptions;
|
||||||
events: number[];
|
events: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const EPSILON = 0.000_001;
|
const EPSILON = 0.000_001;
|
||||||
const finitePositive = (value: unknown) => Number.isFinite(Number(value)) && Number(value) > 0;
|
const finitePositive = (value: unknown) =>
|
||||||
const finiteNonNegative = (value: unknown) => Number.isFinite(Number(value)) && Number(value) >= 0;
|
Number.isFinite(Number(value)) && Number(value) > 0;
|
||||||
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
const finiteNonNegative = (value: unknown) =>
|
||||||
const percent = (value: unknown, fallback = 0) => clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) / 100;
|
Number.isFinite(Number(value)) && Number(value) >= 0;
|
||||||
|
const clamp = (value: number, low: number, high: number) =>
|
||||||
|
Math.min(high, Math.max(low, value));
|
||||||
|
const percent = (value: unknown, fallback = 0) =>
|
||||||
|
clamp(Number.isFinite(Number(value)) ? Number(value) : fallback, 0, 100) /
|
||||||
|
100;
|
||||||
|
|
||||||
function resolveAssumptions(base: CombatAssumptions, effectId: string): ResolvedAssumptions {
|
function resolveAssumptions(
|
||||||
const override = base.perEffect?.[effectId] ?? {};
|
base: CombatAssumptions,
|
||||||
return {
|
effectId: string,
|
||||||
mode: override.mode ?? base.mode,
|
): ResolvedAssumptions {
|
||||||
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
const override = base.perEffect?.[effectId] ?? {};
|
||||||
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
return {
|
||||||
firingActivity: percent(override.firingActivity ?? base.firingActivity, 100),
|
mode: override.mode ?? base.mode,
|
||||||
encounterSeconds: Number(override.encounterSeconds ?? base.encounterSeconds ?? 0)
|
hitRate: percent(override.hitRate ?? base.hitRate, 100),
|
||||||
};
|
headshotRate: percent(override.headshotRate ?? base.headshotRate, 0),
|
||||||
|
firingActivity: percent(
|
||||||
|
override.firingActivity ?? base.firingActivity,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
encounterSeconds: Number(
|
||||||
|
override.encounterSeconds ?? base.encounterSeconds ?? 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformedValue(raw: number, binding: AttributeBinding) {
|
function transformedValue(raw: number, binding: AttributeBinding) {
|
||||||
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
const weight = Number.isFinite(binding.weight) ? Number(binding.weight) : 1;
|
||||||
const sign = raw < 0 ? -1 : 1;
|
const sign = raw < 0 ? -1 : 1;
|
||||||
let value = Math.abs(raw * weight);
|
let value = Math.abs(raw * weight);
|
||||||
const softCap = finiteNonNegative(binding.softCap) ? Number(binding.softCap) : null;
|
const softCap = finiteNonNegative(binding.softCap)
|
||||||
const hardCap = finiteNonNegative(binding.hardCap) ? Number(binding.hardCap) : null;
|
? Number(binding.softCap)
|
||||||
if (softCap !== null && value > softCap && binding.curveType && binding.curveType !== 'none') {
|
: null;
|
||||||
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
const hardCap = finiteNonNegative(binding.hardCap)
|
||||||
const overflow = value - softCap;
|
? Number(binding.hardCap)
|
||||||
if (binding.curveType === 'power') {
|
: null;
|
||||||
value = softCap + overflow * Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
if (
|
||||||
} else {
|
softCap !== null &&
|
||||||
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
value > softCap &&
|
||||||
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
binding.curveType &&
|
||||||
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
binding.curveType !== 'none'
|
||||||
}
|
) {
|
||||||
}
|
const parameter = Math.max(0.01, Number(binding.curveParameter ?? 1));
|
||||||
if (hardCap !== null) value = Math.min(value, hardCap);
|
const overflow = value - softCap;
|
||||||
return value * sign;
|
if (binding.curveType === 'power') {
|
||||||
|
value =
|
||||||
|
softCap +
|
||||||
|
overflow *
|
||||||
|
Math.pow(softCap / Math.max(softCap + overflow, EPSILON), parameter);
|
||||||
|
} else {
|
||||||
|
const ceiling = hardCap ?? Math.max(softCap * 2, softCap + 1);
|
||||||
|
const t = clamp(overflow / Math.max(ceiling - softCap, EPSILON), 0, 1);
|
||||||
|
value = softCap + (ceiling - softCap) * (t * t * (3 - 2 * t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hardCap !== null) value = Math.min(value, hardCap);
|
||||||
|
return value * sign;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAttributes(target: StatValues, incoming: StatValues, multiplier = 1) {
|
function addAttributes(
|
||||||
for (const [id, value] of Object.entries(incoming)) {
|
target: StatValues,
|
||||||
if (Number.isFinite(value)) target[id] = (target[id] ?? 0) + value * multiplier;
|
incoming: StatValues,
|
||||||
}
|
multiplier = 1,
|
||||||
|
) {
|
||||||
|
for (const [id, value] of Object.entries(incoming)) {
|
||||||
|
if (Number.isFinite(value))
|
||||||
|
target[id] = (target[id] ?? 0) + value * multiplier;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
function requiredData(input: CombatCalculationInput): MissingRequirement[] {
|
||||||
const missing: MissingRequirement[] = [];
|
const missing: MissingRequirement[] = [];
|
||||||
if (!finitePositive(input.weapon.baseDamage)) missing.push('weapon.baseDamage');
|
if (!finitePositive(input.weapon.baseDamage))
|
||||||
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
missing.push('weapon.baseDamage');
|
||||||
if (!Number.isInteger(input.weapon.magazine) || Number(input.weapon.magazine) < 1) missing.push('weapon.magazine');
|
if (!finitePositive(input.weapon.rpm)) missing.push('weapon.rpm');
|
||||||
if (!finiteNonNegative(input.weapon.reloadSeconds)) missing.push('weapon.reloadSeconds');
|
if (
|
||||||
const boundIds = new Set(input.bindings.map((binding) => binding.attributeId));
|
!Number.isInteger(input.weapon.magazine) ||
|
||||||
for (const binding of input.bindings) {
|
Number(input.weapon.magazine) < 1
|
||||||
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0)) missing.push(`binding:${binding.attributeId}`);
|
)
|
||||||
}
|
missing.push('weapon.magazine');
|
||||||
if (input.assumptions.mode === 'encounter') {
|
if (!finiteNonNegative(input.weapon.reloadSeconds))
|
||||||
if (!finitePositive(input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
missing.push('weapon.reloadSeconds');
|
||||||
if (!finitePositive(input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
const boundIds = new Set(
|
||||||
if (!finitePositive(input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
input.bindings.map((binding) => binding.attributeId),
|
||||||
}
|
);
|
||||||
for (const effect of input.stackingEffects ?? []) {
|
for (const binding of input.bindings) {
|
||||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
if (!Number.isFinite(input.attributes[binding.attributeId] ?? 0))
|
||||||
if (assumptions.mode === 'encounter') {
|
missing.push(`binding:${binding.attributeId}`);
|
||||||
const override = input.assumptions.perEffect?.[effect.id];
|
}
|
||||||
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate)) missing.push('assumptions.hitRate');
|
if (input.assumptions.mode === 'encounter') {
|
||||||
if (!finitePositive(override?.firingActivity ?? input.assumptions.firingActivity)) missing.push('assumptions.firingActivity');
|
if (!finitePositive(input.assumptions.hitRate))
|
||||||
if (!finitePositive(override?.encounterSeconds ?? input.assumptions.encounterSeconds)) missing.push('assumptions.encounterSeconds');
|
missing.push('assumptions.hitRate');
|
||||||
}
|
if (!finitePositive(input.assumptions.firingActivity))
|
||||||
if (assumptions.mode !== 'off') for (const id of Object.keys(effect.attributes)) {
|
missing.push('assumptions.firingActivity');
|
||||||
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
if (!finitePositive(input.assumptions.encounterSeconds))
|
||||||
}
|
missing.push('assumptions.encounterSeconds');
|
||||||
}
|
}
|
||||||
return [...new Set(missing)];
|
for (const effect of input.stackingEffects ?? []) {
|
||||||
|
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||||
|
if (assumptions.mode === 'encounter') {
|
||||||
|
const override = input.assumptions.perEffect?.[effect.id];
|
||||||
|
if (!finitePositive(override?.hitRate ?? input.assumptions.hitRate))
|
||||||
|
missing.push('assumptions.hitRate');
|
||||||
|
if (
|
||||||
|
!finitePositive(
|
||||||
|
override?.firingActivity ?? input.assumptions.firingActivity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
missing.push('assumptions.firingActivity');
|
||||||
|
if (
|
||||||
|
!finitePositive(
|
||||||
|
override?.encounterSeconds ?? input.assumptions.encounterSeconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
missing.push('assumptions.encounterSeconds');
|
||||||
|
}
|
||||||
|
if (assumptions.mode !== 'off')
|
||||||
|
for (const id of Object.keys(effect.attributes)) {
|
||||||
|
if (!boundIds.has(id)) missing.push(`binding:${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(missing)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function stageValues(attributes: StatValues, bindings: AttributeBinding[], stage: CalculationStage) {
|
function stageValues(
|
||||||
let additive = 0;
|
attributes: StatValues,
|
||||||
let multiplicative = 1;
|
bindings: AttributeBinding[],
|
||||||
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
stage: CalculationStage,
|
||||||
const value = transformedValue(Number(attributes[binding.attributeId] ?? 0), binding);
|
) {
|
||||||
if (binding.operator === 'add') additive += value;
|
let additive = 0;
|
||||||
else multiplicative *= value;
|
let multiplicative = 1;
|
||||||
}
|
for (const binding of bindings.filter((item) => item.stage === stage)) {
|
||||||
return { additive, multiplicative };
|
const value = transformedValue(
|
||||||
|
Number(attributes[binding.attributeId] ?? 0),
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
if (binding.operator === 'add') additive += value;
|
||||||
|
else multiplicative *= value;
|
||||||
|
}
|
||||||
|
return { additive, multiplicative };
|
||||||
}
|
}
|
||||||
|
|
||||||
function damageBase(
|
function damageBase(
|
||||||
weapon: Required<WeaponCombatInput>,
|
weapon: Required<WeaponCombatInput>,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
) {
|
) {
|
||||||
let damage = weapon.baseDamage;
|
let damage = weapon.baseDamage;
|
||||||
const order = stageOrder?.length
|
const order = stageOrder?.length
|
||||||
? stageOrder
|
? stageOrder
|
||||||
: ['flat_damage', 'weapon_damage', 'final_damage'] satisfies CalculationStage[];
|
: ([
|
||||||
for (const stage of order) {
|
'flat_damage',
|
||||||
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage)) continue;
|
'weapon_damage',
|
||||||
const values = stageValues(attributes, bindings, stage);
|
'final_damage',
|
||||||
if (stage === 'flat_damage') damage = (damage + values.additive) * values.multiplicative;
|
] satisfies CalculationStage[]);
|
||||||
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
for (const stage of order) {
|
||||||
}
|
if (!['flat_damage', 'weapon_damage', 'final_damage'].includes(stage))
|
||||||
return damage;
|
continue;
|
||||||
|
const values = stageValues(attributes, bindings, stage);
|
||||||
|
if (stage === 'flat_damage')
|
||||||
|
damage = (damage + values.additive) * values.multiplicative;
|
||||||
|
else damage *= (1 + values.additive / 100) * values.multiplicative;
|
||||||
|
}
|
||||||
|
return damage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hitValues(
|
function hitValues(
|
||||||
weapon: Required<WeaponCombatInput>,
|
weapon: Required<WeaponCombatInput>,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
): DamageValues {
|
): DamageValues {
|
||||||
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
const normal = damageBase(weapon, attributes, bindings, stageOrder);
|
||||||
const critical = stageValues(attributes, bindings, 'critical_damage');
|
const critical = stageValues(attributes, bindings, 'critical_damage');
|
||||||
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
const headshot = stageValues(attributes, bindings, 'headshot_damage');
|
||||||
const criticalFactor = (1 + critical.additive / 100) * critical.multiplicative;
|
const criticalFactor =
|
||||||
const headshotFactor = weapon.headshotMultiplier * (1 + headshot.additive / 100) * headshot.multiplicative;
|
(1 + critical.additive / 100) * critical.multiplicative;
|
||||||
return {
|
const headshotFactor =
|
||||||
normal,
|
weapon.headshotMultiplier *
|
||||||
critical: normal * criticalFactor,
|
(1 + headshot.additive / 100) *
|
||||||
headshot: normal * headshotFactor,
|
headshot.multiplicative;
|
||||||
criticalHeadshot: normal * criticalFactor * headshotFactor
|
return {
|
||||||
};
|
normal,
|
||||||
|
critical: normal * criticalFactor,
|
||||||
|
headshot: normal * headshotFactor,
|
||||||
|
criticalHeadshot: normal * criticalFactor * headshotFactor,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function dpsFor(
|
function dpsFor(
|
||||||
weapon: Required<WeaponCombatInput>,
|
weapon: Required<WeaponCombatInput>,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
assumptions: ResolvedAssumptions,
|
assumptions: ResolvedAssumptions,
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
) {
|
) {
|
||||||
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
const values = hitValues(weapon, attributes, bindings, stageOrder);
|
||||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||||
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
const boundCriticalChance = clamp(critical.additive / 100, 0, 1);
|
||||||
const configuredHeadshot = stageValues(attributes, bindings, 'headshot_chance');
|
const configuredHeadshot = stageValues(
|
||||||
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
attributes,
|
||||||
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
bindings,
|
||||||
const average = values.normal * (1 - boundCriticalChance) * (1 - headshotChance)
|
'headshot_chance',
|
||||||
+ values.critical * boundCriticalChance * (1 - headshotChance)
|
);
|
||||||
+ values.headshot * (1 - boundCriticalChance) * headshotChance
|
const boundHeadshotChance = clamp(configuredHeadshot.additive / 100, 0, 1);
|
||||||
+ values.criticalHeadshot * boundCriticalChance * headshotChance;
|
const headshotChance = assumptions.headshotRate || boundHeadshotChance;
|
||||||
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
const average =
|
||||||
weapon,
|
values.normal * (1 - boundCriticalChance) * (1 - headshotChance) +
|
||||||
attributes,
|
values.critical * boundCriticalChance * (1 - headshotChance) +
|
||||||
bindings
|
values.headshot * (1 - boundCriticalChance) * headshotChance +
|
||||||
);
|
values.criticalHeadshot * boundCriticalChance * headshotChance;
|
||||||
const shotsPerSecond = rpm / 60;
|
const { rpm, magazine, reloadSeconds } = resolveWeaponFiringCycle(
|
||||||
return {
|
weapon,
|
||||||
values,
|
attributes,
|
||||||
burst: average * shotsPerSecond,
|
bindings,
|
||||||
sustained: average * magazine / (magazine / shotsPerSecond + reloadSeconds)
|
);
|
||||||
};
|
const shotsPerSecond = rpm / 60;
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
burst: average * shotsPerSecond,
|
||||||
|
sustained:
|
||||||
|
(average * magazine) / (magazine / shotsPerSecond + reloadSeconds),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function expectedWeaponShotDamage(
|
export function expectedWeaponShotDamage(
|
||||||
weapon: WeaponCombatInput,
|
weapon: WeaponCombatInput,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[],
|
bindings: AttributeBinding[],
|
||||||
headshot = false,
|
headshot = false,
|
||||||
stageOrder?: CalculationStage[]
|
stageOrder?: CalculationStage[],
|
||||||
) {
|
) {
|
||||||
const resolvedWeapon: Required<WeaponCombatInput> = {
|
const resolvedWeapon: Required<WeaponCombatInput> = {
|
||||||
baseDamage: Number(weapon.baseDamage),
|
baseDamage: Number(weapon.baseDamage),
|
||||||
rpm: Number(weapon.rpm),
|
rpm: Number(weapon.rpm),
|
||||||
magazine: Number(weapon.magazine),
|
magazine: Number(weapon.magazine),
|
||||||
reloadSeconds: Number(weapon.reloadSeconds),
|
reloadSeconds: Number(weapon.reloadSeconds),
|
||||||
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2)
|
headshotMultiplier: Number(weapon.headshotMultiplier ?? 2),
|
||||||
};
|
};
|
||||||
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
|
const values = hitValues(resolvedWeapon, attributes, bindings, stageOrder);
|
||||||
const critical = stageValues(attributes, bindings, 'critical_chance');
|
const critical = stageValues(attributes, bindings, 'critical_chance');
|
||||||
const criticalChance = clamp(critical.additive / 100, 0, 1);
|
const criticalChance = clamp(critical.additive / 100, 0, 1);
|
||||||
return headshot
|
return headshot
|
||||||
? values.headshot * (1 - criticalChance) + values.criticalHeadshot * criticalChance
|
? values.headshot * (1 - criticalChance) +
|
||||||
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
|
values.criticalHeadshot * criticalChance
|
||||||
|
: values.normal * (1 - criticalChance) + values.critical * criticalChance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWeaponFiringCycle(
|
export function resolveWeaponFiringCycle(
|
||||||
weapon: WeaponCombatInput,
|
weapon: WeaponCombatInput,
|
||||||
attributes: StatValues,
|
attributes: StatValues,
|
||||||
bindings: AttributeBinding[]
|
bindings: AttributeBinding[],
|
||||||
): WeaponFiringCycle {
|
): WeaponFiringCycle {
|
||||||
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
|
const rateOfFire = stageValues(attributes, bindings, 'rate_of_fire');
|
||||||
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
|
const magazineSize = stageValues(attributes, bindings, 'magazine_size');
|
||||||
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
|
const reloadSpeed = stageValues(attributes, bindings, 'reload_speed');
|
||||||
return {
|
return {
|
||||||
rpm: Number(weapon.rpm)
|
rpm:
|
||||||
* (1 + rateOfFire.additive / 100)
|
Number(weapon.rpm) *
|
||||||
* rateOfFire.multiplicative,
|
(1 + rateOfFire.additive / 100) *
|
||||||
magazine: Math.max(
|
rateOfFire.multiplicative,
|
||||||
1,
|
magazine: Math.max(
|
||||||
Number(weapon.magazine)
|
1,
|
||||||
* (1 + magazineSize.additive / 100)
|
Number(weapon.magazine) *
|
||||||
* magazineSize.multiplicative
|
(1 + magazineSize.additive / 100) *
|
||||||
),
|
magazineSize.multiplicative,
|
||||||
reloadSeconds: Number(weapon.reloadSeconds) / Math.max(
|
),
|
||||||
EPSILON,
|
reloadSeconds:
|
||||||
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative
|
Number(weapon.reloadSeconds) /
|
||||||
)
|
Math.max(
|
||||||
};
|
EPSILON,
|
||||||
|
(1 + reloadSpeed.additive / 100) * reloadSpeed.multiplicative,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerEvents(weapon: Required<WeaponCombatInput>, effect: StackingEffect, assumptions: ResolvedAssumptions) {
|
function triggerEvents(
|
||||||
if (assumptions.mode !== 'encounter' || effect.trigger.hitsRequired < 1 || effect.trigger.windowSeconds <= 0) return [];
|
weapon: Required<WeaponCombatInput>,
|
||||||
const shotInterval = (60 / weapon.rpm) / Math.max(assumptions.firingActivity, EPSILON);
|
effect: StackingEffect,
|
||||||
let magazineRemaining = weapon.magazine;
|
assumptions: ResolvedAssumptions,
|
||||||
let expectedHits = 0;
|
) {
|
||||||
let counterStartedAt = 0;
|
if (
|
||||||
const events: number[] = [];
|
assumptions.mode !== 'encounter' ||
|
||||||
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
effect.trigger.hitsRequired < 1 ||
|
||||||
if (magazineRemaining === 0) {
|
effect.trigger.windowSeconds <= 0
|
||||||
time += weapon.reloadSeconds;
|
)
|
||||||
magazineRemaining = weapon.magazine;
|
return [];
|
||||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
const shotInterval =
|
||||||
expectedHits = 0;
|
60 / weapon.rpm / Math.max(assumptions.firingActivity, EPSILON);
|
||||||
counterStartedAt = time;
|
let magazineRemaining = weapon.magazine;
|
||||||
}
|
let expectedHits = 0;
|
||||||
continue;
|
let counterStartedAt = 0;
|
||||||
}
|
const events: number[] = [];
|
||||||
if (expectedHits === 0) counterStartedAt = time;
|
for (let time = 0; time <= assumptions.encounterSeconds + EPSILON;) {
|
||||||
expectedHits += assumptions.hitRate;
|
if (magazineRemaining === 0) {
|
||||||
magazineRemaining -= 1;
|
time += weapon.reloadSeconds;
|
||||||
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
magazineRemaining = weapon.magazine;
|
||||||
expectedHits = assumptions.hitRate;
|
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||||
counterStartedAt = time;
|
expectedHits = 0;
|
||||||
}
|
counterStartedAt = time;
|
||||||
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
}
|
||||||
events.push(time);
|
continue;
|
||||||
expectedHits = 0;
|
}
|
||||||
counterStartedAt = time;
|
if (expectedHits === 0) counterStartedAt = time;
|
||||||
}
|
expectedHits += assumptions.hitRate;
|
||||||
time += shotInterval;
|
magazineRemaining -= 1;
|
||||||
}
|
if (time - counterStartedAt > effect.trigger.windowSeconds) {
|
||||||
return events;
|
expectedHits = assumptions.hitRate;
|
||||||
|
counterStartedAt = time;
|
||||||
|
}
|
||||||
|
if (expectedHits + EPSILON >= effect.trigger.hitsRequired) {
|
||||||
|
events.push(time);
|
||||||
|
expectedHits = 0;
|
||||||
|
counterStartedAt = time;
|
||||||
|
}
|
||||||
|
time += shotInterval;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
function stateAt(effect: TimedStackEffect, time: number): StackState {
|
||||||
if (effect.assumptions.mode === 'off') {
|
if (effect.assumptions.mode === 'off') {
|
||||||
return { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
return {
|
||||||
}
|
stacks: 0,
|
||||||
if (effect.assumptions.mode === 'peak') {
|
expiresAt: 0,
|
||||||
return {
|
maxStacksReached: 0,
|
||||||
stacks: effect.effect.lifecycle.maxStacks,
|
buildUpSeconds: null,
|
||||||
expiresAt: Infinity,
|
};
|
||||||
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
}
|
||||||
buildUpSeconds: 0
|
if (effect.assumptions.mode === 'peak') {
|
||||||
};
|
return {
|
||||||
}
|
stacks: effect.effect.lifecycle.maxStacks,
|
||||||
const state: StackState = { stacks: 0, expiresAt: 0, maxStacksReached: 0, buildUpSeconds: null };
|
expiresAt: Infinity,
|
||||||
for (const eventTime of effect.events) {
|
maxStacksReached: effect.effect.lifecycle.maxStacks,
|
||||||
if (eventTime - time > EPSILON) break;
|
buildUpSeconds: 0,
|
||||||
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
};
|
||||||
state.stacks = Math.min(
|
}
|
||||||
effect.effect.lifecycle.maxStacks,
|
const state: StackState = {
|
||||||
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1)
|
stacks: 0,
|
||||||
);
|
expiresAt: 0,
|
||||||
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
maxStacksReached: 0,
|
||||||
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
buildUpSeconds: null,
|
||||||
if (state.stacks === effect.effect.lifecycle.maxStacks && state.buildUpSeconds === null) {
|
};
|
||||||
state.buildUpSeconds = eventTime;
|
for (const eventTime of effect.events) {
|
||||||
}
|
if (eventTime - time > EPSILON) break;
|
||||||
}
|
if (eventTime - state.expiresAt > EPSILON) state.stacks = 0;
|
||||||
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
state.stacks = Math.min(
|
||||||
return state;
|
effect.effect.lifecycle.maxStacks,
|
||||||
|
state.stacks + Math.max(1, effect.effect.lifecycle.stacksPerTrigger ?? 1),
|
||||||
|
);
|
||||||
|
state.expiresAt = eventTime + effect.effect.lifecycle.durationSeconds;
|
||||||
|
state.maxStacksReached = Math.max(state.maxStacksReached, state.stacks);
|
||||||
|
if (
|
||||||
|
state.stacks === effect.effect.lifecycle.maxStacks &&
|
||||||
|
state.buildUpSeconds === null
|
||||||
|
) {
|
||||||
|
state.buildUpSeconds = eventTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (time - state.expiresAt > EPSILON) state.stacks = 0;
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stackAttributes(base: StatValues, effects: TimedStackEffect[], time: number) {
|
function stackAttributes(
|
||||||
const result = { ...base };
|
base: StatValues,
|
||||||
for (const effect of effects) addAttributes(result, effect.effect.attributes, stateAt(effect, time).stacks);
|
effects: TimedStackEffect[],
|
||||||
return result;
|
time: number,
|
||||||
|
) {
|
||||||
|
const result = { ...base };
|
||||||
|
for (const effect of effects)
|
||||||
|
addAttributes(
|
||||||
|
result,
|
||||||
|
effect.effect.attributes,
|
||||||
|
stateAt(effect, time).stacks,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateCombat(input: CombatCalculationInput): CombatCalculation {
|
export function calculateCombat(
|
||||||
const missing = requiredData(input);
|
input: CombatCalculationInput,
|
||||||
if (missing.length) {
|
): CombatCalculation {
|
||||||
return {
|
const missing = requiredData(input);
|
||||||
status: 'INSUFFICIENT_DATA',
|
if (missing.length) {
|
||||||
missing,
|
return {
|
||||||
hitValues: null,
|
status: 'INSUFFICIENT_DATA',
|
||||||
burstDps: null,
|
missing,
|
||||||
sustainedDps: null,
|
hitValues: null,
|
||||||
encounterDps: null,
|
burstDps: null,
|
||||||
stackSummaries: []
|
sustainedDps: null,
|
||||||
};
|
encounterDps: null,
|
||||||
}
|
stackSummaries: [],
|
||||||
const weapon: Required<WeaponCombatInput> = {
|
};
|
||||||
baseDamage: Number(input.weapon.baseDamage),
|
}
|
||||||
rpm: Number(input.weapon.rpm),
|
const weapon: Required<WeaponCombatInput> = {
|
||||||
magazine: Number(input.weapon.magazine),
|
baseDamage: Number(input.weapon.baseDamage),
|
||||||
reloadSeconds: Number(input.weapon.reloadSeconds),
|
rpm: Number(input.weapon.rpm),
|
||||||
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2)
|
magazine: Number(input.weapon.magazine),
|
||||||
};
|
reloadSeconds: Number(input.weapon.reloadSeconds),
|
||||||
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map((effect) => {
|
headshotMultiplier: Number(input.weapon.headshotMultiplier ?? 2),
|
||||||
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
};
|
||||||
return { effect, assumptions, events: triggerEvents(weapon, effect, assumptions) };
|
const effects: TimedStackEffect[] = (input.stackingEffects ?? []).map(
|
||||||
});
|
(effect) => {
|
||||||
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
const assumptions = resolveAssumptions(input.assumptions, effect.id);
|
||||||
const finalTime = Math.max(
|
return {
|
||||||
globalAssumptions.encounterSeconds,
|
effect,
|
||||||
...effects.filter((effect) => effect.assumptions.mode === 'encounter').map((effect) => effect.assumptions.encounterSeconds)
|
assumptions,
|
||||||
);
|
events: triggerEvents(weapon, effect, assumptions),
|
||||||
const current = dpsFor(
|
};
|
||||||
weapon,
|
},
|
||||||
stackAttributes(input.attributes, effects, finalTime),
|
);
|
||||||
input.bindings,
|
const globalAssumptions = resolveAssumptions(input.assumptions, '');
|
||||||
globalAssumptions,
|
const finalTime = Math.max(
|
||||||
input.stageOrder
|
globalAssumptions.encounterSeconds,
|
||||||
);
|
...effects
|
||||||
let encounterDps: number | null = null;
|
.filter((effect) => effect.assumptions.mode === 'encounter')
|
||||||
if (effects.some((effect) => effect.assumptions.mode === 'encounter') && finalTime > 0) {
|
.map((effect) => effect.assumptions.encounterSeconds),
|
||||||
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
);
|
||||||
let total = 0;
|
const current = dpsFor(
|
||||||
for (let index = 0; index < steps; index += 1) {
|
weapon,
|
||||||
const time = finalTime * (index + 0.5) / steps;
|
stackAttributes(input.attributes, effects, finalTime),
|
||||||
const result = dpsFor(
|
input.bindings,
|
||||||
weapon,
|
globalAssumptions,
|
||||||
stackAttributes(input.attributes, effects, time),
|
input.stageOrder,
|
||||||
input.bindings,
|
);
|
||||||
globalAssumptions,
|
let encounterDps: number | null = null;
|
||||||
input.stageOrder
|
if (
|
||||||
);
|
effects.some((effect) => effect.assumptions.mode === 'encounter') &&
|
||||||
total += result.sustained * globalAssumptions.firingActivity;
|
finalTime > 0
|
||||||
}
|
) {
|
||||||
encounterDps = total / steps;
|
const steps = Math.max(30, Math.min(600, Math.ceil(finalTime * 4)));
|
||||||
}
|
let total = 0;
|
||||||
return {
|
for (let index = 0; index < steps; index += 1) {
|
||||||
status: 'OK',
|
const time = (finalTime * (index + 0.5)) / steps;
|
||||||
missing: [],
|
const result = dpsFor(
|
||||||
hitValues: current.values,
|
weapon,
|
||||||
burstDps: current.burst,
|
stackAttributes(input.attributes, effects, time),
|
||||||
sustainedDps: current.sustained,
|
input.bindings,
|
||||||
encounterDps,
|
globalAssumptions,
|
||||||
stackSummaries: effects.map((item) => {
|
input.stageOrder,
|
||||||
const end = stateAt(item, finalTime);
|
);
|
||||||
const duration = item.assumptions.mode === 'encounter' ? item.assumptions.encounterSeconds : 0;
|
total += result.sustained * globalAssumptions.firingActivity;
|
||||||
const samples = item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
}
|
||||||
let sum = 0;
|
encounterDps = total / steps;
|
||||||
for (let index = 0; index < samples; index += 1) {
|
}
|
||||||
sum += stateAt(item, duration * (index + 0.5) / samples).stacks;
|
return {
|
||||||
}
|
status: 'OK',
|
||||||
return {
|
missing: [],
|
||||||
effectId: item.effect.id,
|
hitValues: current.values,
|
||||||
mode: item.assumptions.mode,
|
burstDps: current.burst,
|
||||||
stacksAtEnd: end.stacks,
|
sustainedDps: current.sustained,
|
||||||
maxStacksReached: end.maxStacksReached,
|
encounterDps,
|
||||||
buildUpSeconds: end.buildUpSeconds,
|
stackSummaries: effects.map((item) => {
|
||||||
averageStacks: sum / samples,
|
const end = stateAt(item, finalTime);
|
||||||
maintained: end.maxStacksReached === item.effect.lifecycle.maxStacks
|
const duration =
|
||||||
&& (item.assumptions.mode === 'peak' || end.stacks > 0)
|
item.assumptions.mode === 'encounter'
|
||||||
};
|
? item.assumptions.encounterSeconds
|
||||||
})
|
: 0;
|
||||||
};
|
const samples =
|
||||||
|
item.assumptions.mode === 'encounter' && duration > 0 ? 100 : 1;
|
||||||
|
let sum = 0;
|
||||||
|
for (let index = 0; index < samples; index += 1) {
|
||||||
|
sum += stateAt(item, (duration * (index + 0.5)) / samples).stacks;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
effectId: item.effect.id,
|
||||||
|
mode: item.assumptions.mode,
|
||||||
|
stacksAtEnd: end.stacks,
|
||||||
|
maxStacksReached: end.maxStacksReached,
|
||||||
|
buildUpSeconds: end.buildUpSeconds,
|
||||||
|
averageStacks: sum / samples,
|
||||||
|
maintained:
|
||||||
|
end.maxStacksReached === item.effect.lifecycle.maxStacks &&
|
||||||
|
(item.assumptions.mode === 'peak' || end.stacks > 0),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user